← back to Ga4 Fleet
provision.mjs
141 lines
#!/usr/bin/env node
// GA4 fleet provisioner — idempotent property + web-stream creation via the Analytics Admin API.
// Operator-run: authenticates with a service-account key that the OPERATOR provides explicitly
// via GA4_SA_KEY. It does NOT search for or discover credentials — you point it at one key file
// you already control, and it uses only that. The SA must be granted Admin on the target account.
//
// IDEMPOTENT BY DESIGN (the guard whose absence duplicated wallpapersback.com):
// 1. Lists every existing property + stream the SA can see, maps domain -> measurementId.
// 2. Only creates a property when the domain has no existing stream. Re-runs never duplicate.
//
// USAGE (operator runs this — set GA4_SA_KEY to your own key path):
// GA4_SA_KEY=/path/key.json node provision.mjs --account accounts/15714274 # DRY RUN
// GA4_SA_KEY=/path/key.json node provision.mjs --account accounts/15714274 --commit # create
// GA4_SA_KEY=/path/key.json node provision.mjs --account accounts/15714274 --limit 10 # first N
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { createSign } from 'node:crypto';
const DIR = '/Users/macstudio3/Projects/ga4-fleet';
const API = 'https://analyticsadmin.googleapis.com/v1beta';
const SCOPE = 'https://www.googleapis.com/auth/analytics.edit';
// ---- args ----
const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
const COMMIT = has('--commit');
const ACCOUNT = val('--account', null); // e.g. accounts/15714274 ; null = auto-list first accessible
const LIMIT = Number(val('--limit', '0')) || Infinity;
// ---- Auth: SIMPLEST first. If GA4_TOKEN is set (your own gcloud access token), use it
// directly — no service account, no key file. You already own the GA4 accounts, so:
// GA4_TOKEN=$(gcloud auth print-access-token) node provision.mjs ...
// Fallback: GA4_SA_KEY=/path/key.json for a service-account key (JWT), if you prefer that.
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
async function getToken() {
if (process.env.GA4_TOKEN) return process.env.GA4_TOKEN; // your gcloud login token — simplest path
const p = process.env.GA4_SA_KEY;
if (!p || !existsSync(p)) throw new Error('Set GA4_TOKEN=$(gcloud auth print-access-token) — or GA4_SA_KEY=/path/key.json for a service account.');
const key = JSON.parse(readFileSync(p, 'utf8'));
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claim = b64url(JSON.stringify({
iss: key.client_email, scope: SCOPE, aud: 'https://oauth2.googleapis.com/token',
iat: now, exp: now + 3600,
}));
const sig = createSign('RSA-SHA256').update(`${header}.${claim}`).sign(key.private_key);
const jwt = `${header}.${claim}.${b64url(sig)}`;
const r = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`,
});
const j = await r.json();
if (!j.access_token) throw new Error('token exchange failed: ' + JSON.stringify(j));
return j.access_token;
}
let TOKEN;
async function api(path, method = 'GET', body) {
const r = await fetch(`${API}/${path}`, {
method, headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(j)}`);
return j;
}
// ---- existing coverage: domain -> measurementId across all visible streams ----
async function existingByDomain() {
const map = {};
// ALWAYS scan every accessible account for the existence check — a domain's property may
// live under a different account than --account (which only sets where NEW ones get created).
// Scoping this to --account is how you mint a duplicate of a property that already exists elsewhere.
const accts = (await api('accounts')).accounts || [];
for (const a of accts) {
let pageToken;
do {
const pr = await api(`properties?filter=parent:${a.name}${pageToken ? `&pageToken=${pageToken}` : ''}`);
for (const p of pr.properties || []) {
const ds = await api(`${p.name}/dataStreams`);
for (const s of ds.dataStreams || []) {
const uri = s.webStreamData?.defaultUri || '';
const mid = s.webStreamData?.measurementId || '';
const host = uri.replace(/^https?:\/\//, '').replace(/\/$/, '').replace(/^www\./, '');
if (host && mid) map[host] = mid;
}
}
pageToken = pr.nextPageToken;
} while (pageToken);
}
return map;
}
async function createPropertyAndStream(account, domain, displayName) {
const prop = await api('properties', 'POST', {
parent: account, displayName, timeZone: 'America/Los_Angeles', currencyCode: 'USD',
industryCategory: 'HOME_AND_GARDEN',
});
const stream = await api(`${prop.name}/dataStreams`, 'POST', {
type: 'WEB_DATA_STREAM', displayName: `${displayName} - Web`,
webStreamData: { defaultUri: `https://${domain}` },
});
return { property: prop.name, measurementId: stream.webStreamData?.measurementId };
}
function niceName(domain) {
return domain.replace(/\.(com|net|org|co)$/, '').replace(/[.-]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase()).replace(/Designerwallcoverings/i, 'DW').slice(0, 80);
}
// ---- main ----
(async () => {
const domains = readFileSync(`${DIR}/domains-public.txt`, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean);
const registryPath = `${DIR}/measurement-ids.json`;
const registry = existsSync(registryPath) ? JSON.parse(readFileSync(registryPath, 'utf8')) : {};
console.log(`${COMMIT ? 'COMMIT' : 'DRY RUN'} · ${domains.length} domains · account=${ACCOUNT || 'auto'}`);
TOKEN = await getToken();
console.log('SA authenticated ✓');
const existing = await existingByDomain();
console.log(`existing streams found: ${Object.keys(existing).length}`);
let created = 0, skipped = 0, planned = 0;
for (const domain of domains.slice(0, LIMIT === Infinity ? domains.length : LIMIT)) {
const key = domain.replace(/^www\./, '');
if (existing[key]) { registry[key] = existing[key]; skipped++; console.log(`SKIP ${key} — already has ${existing[key]}`); continue; }
if (registry[key] && registry[key].startsWith('G-')) { skipped++; console.log(`SKIP ${key} — in registry ${registry[key]}`); continue; }
if (!COMMIT) { planned++; console.log(`PLAN ${key} — would create property + web stream`); continue; }
try {
const acct = ACCOUNT || (await api('accounts')).accounts?.[0]?.name;
const { property, measurementId } = await createPropertyAndStream(acct, key, niceName(key));
registry[key] = measurementId;
writeFileSync(registryPath, JSON.stringify(registry, null, 2));
created++; console.log(`CREATE ${key} -> ${measurementId} (${property})`);
} catch (e) { console.log(`ERROR ${key}: ${e.message}`); }
}
writeFileSync(registryPath, JSON.stringify(registry, null, 2));
console.log(`\ndone · created=${created} · skipped(existing)=${skipped} · planned=${planned}`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });