fg-devkit 1.17.3 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Maintainer tool for Piwik PRO custom dimensions.
4
+ *
5
+ * Reads the consumer's local slot map (src/analytics/dimensions.ts or
6
+ * src/util/piwikDimensions.ts) and compares it with the site in Piwik.
7
+ * Session and event dimensions are created one at a time, and only when the
8
+ * next free slot in that scope is the local slot. Product dimensions send the
9
+ * local slot (1–20). There is no delete: deactivating a dimension does not
10
+ * free its slot.
11
+ *
12
+ * pnpm exec fg-devkit piwik list [--local] [--env preprod] [--scope event]
13
+ * pnpm exec fg-devkit piwik diff
14
+ * pnpm exec fg-devkit piwik add [--yes] [--dry-run]
15
+ * pnpm exec fg-devkit piwik update [--yes] [--dry-run]
16
+ * pnpm exec fg-devkit piwik sync [--yes] [--dry-run]
17
+ * pnpm exec fg-devkit piwik pull [--env preprod] [--yes] [--dry-run]
18
+ * pnpm exec fg-devkit piwik compare [--from preprod] [--to production]
19
+ * pnpm exec fg-devkit piwik migrate [--from preprod] [--to production] [--yes] [--dry-run]
20
+ *
21
+ * Credentials: PIWIK_CLIENT_ID and PIWIK_CLIENT_SECRET (env, .env.local, or env.local).
22
+ * Site: --account / --website-id, piwik/.piwik.json, or src/config/env.ts.
23
+ * Optional piwik/.piwik.json names override the Piwik label for a local key.
24
+ */
25
+
26
+ import fs from 'node:fs'
27
+
28
+ import {
29
+ PiwikDimensionError,
30
+ SCOPES,
31
+ diffDimensions,
32
+ diffEnvironments,
33
+ formatDimensionReport,
34
+ formatEnvironmentReport,
35
+ planCreates,
36
+ planEnvironmentMigration,
37
+ planLocalPull,
38
+ renderTable,
39
+ rewriteDimensionNumbers,
40
+ } from './piwik-dimensions.mjs'
41
+ import {
42
+ assertApiConfig,
43
+ createPiwikClient,
44
+ loadLocalDimensions,
45
+ resolveEnvironmentPair,
46
+ resolvePiwikConfig,
47
+ } from './piwik-common.mjs'
48
+
49
+ const COMMANDS = new Set(['list', 'diff', 'add', 'update', 'sync', 'pull', 'compare', 'migrate'])
50
+
51
+ function printHelp() {
52
+ console.log(`Usage:
53
+ pnpm exec fg-devkit piwik <command> [options]
54
+
55
+ Commands:
56
+ list List dimensions in Piwik. --local prints the repo map and skips the API.
57
+ diff Compare the local slot map with Piwik. Exits 1 when they differ.
58
+ add Create local dimensions that are missing in Piwik, in slot order.
59
+ update Rename Piwik dimensions whose name differs from the local key.
60
+ sync Copy the local map up to Piwik: add missing dimensions, then rename. Does not edit the repo.
61
+ pull Copy tracking ids from Piwik down into the local map for keys with the same name.
62
+ compare Compare two sites from src/config/env.ts. Exits 1 when they differ.
63
+ migrate Copy dimensions from --from onto --to. Creates one at a time and checks tracking_id.
64
+
65
+ Options:
66
+ --env <name> Environment object in src/config/env.ts (default: first non-production site, such as preprod)
67
+ --from <name> Source site for compare and migrate (default: first non-production site)
68
+ --to <name> Target site for compare and migrate (default: production when that site exists)
69
+ --account <name> Piwik account (the subdomain of {account}.piwik.pro)
70
+ --website-id <uuid> Site id. Defaults to piwikId / piwikProId for --env
71
+ --dimensions <file> Local map. Default: src/analytics/dimensions.ts or src/util/piwikDimensions.ts
72
+ --config <file> Optional piwik/.piwik.json (account, websiteId, environment, dimensionsFile, names)
73
+ --scope <event|session|product>
74
+ --client-id <id> Overrides PIWIK_CLIENT_ID
75
+ --client-secret <secret>
76
+ --yes Apply add, update, or sync. Without this, a TTY asks first and other runs only print the plan.
77
+ --dry-run Print the plan and do not call create or update
78
+ --local With list: print only the local map
79
+
80
+ The tracker sends dimension{slot}. Slot and tracking_id are read-only and assigned
81
+ by Piwik for session and event dimensions. A create stops the loop if the returned
82
+ slot is not the local slot.`)
83
+ }
84
+
85
+ /**
86
+ * @param {import('./piwik-dimensions.mjs').LocalDimension[]} locals
87
+ */
88
+ function printLocal(locals) {
89
+ for (const scope of SCOPES) {
90
+ const scoped = locals.filter((item) => item.scope === scope).sort((a, b) => a.slot - b.slot)
91
+ if (scoped.length === 0) continue
92
+ console.log(`\n${scope}`)
93
+ console.log(`${'slot'.padEnd(6)}key`)
94
+ for (const local of scoped) {
95
+ console.log(`${String(local.slot).padEnd(6)}${local.name}`)
96
+ }
97
+ }
98
+ const counts = SCOPES.map((scope) => `${locals.filter((item) => item.scope === scope).length} ${scope}`).join(', ')
99
+ console.log(`\n[piwik] ${locals.length} local dimensions (${counts})`)
100
+ }
101
+
102
+ /**
103
+ * @param {import('./piwik-dimensions.mjs').RemoteDimension[]} dimensions
104
+ * @param {Record<string, { used?: number, available?: number, left?: number }>} slots
105
+ */
106
+ function printRemote(dimensions, slots) {
107
+ const slotLine = SCOPES.map((scope) => {
108
+ const stats = slots?.[scope]
109
+ if (!stats) return `${scope} ?`
110
+ return `${scope} ${stats.used ?? '?'}/${stats.available ?? '?'} (${stats.left ?? '?'} left)`
111
+ }).join(' ')
112
+ console.log(`[piwik] slots ${slotLine}`)
113
+ for (const scope of SCOPES) {
114
+ const scoped = dimensions.filter((item) => item.scope === scope).sort((a, b) => a.slot - b.slot)
115
+ if (scoped.length === 0) continue
116
+ console.log(`\n${scope}`)
117
+ const showDescription = scoped.some((remote) => remote.description.trim() !== '')
118
+ const headers = ['slot', 'tracking_id', 'state', 'name']
119
+ if (showDescription) headers.push('description')
120
+ console.log(
121
+ renderTable(
122
+ headers,
123
+ scoped.map((remote) => {
124
+ const cells = [String(remote.slot), String(remote.trackingId), remote.active ? 'active' : 'inactive', remote.name]
125
+ if (showDescription) cells.push(remote.description.trim())
126
+ return cells
127
+ }),
128
+ ).join('\n'),
129
+ )
130
+ }
131
+ console.log(`\n[piwik] ${dimensions.length} dimensions in Piwik`)
132
+ }
133
+
134
+ /**
135
+ * @param {string} message
136
+ * @param {{ yes: boolean, dryRun: boolean }} config
137
+ */
138
+ async function confirmApply(message, config) {
139
+ if (config.dryRun) return false
140
+ if (config.yes) return true
141
+ if (!process.stdin.isTTY) {
142
+ console.log('[piwik] Not applied. Re-run with --yes to write to Piwik.')
143
+ return false
144
+ }
145
+ const { default: confirm } = await import('@inquirer/confirm')
146
+ return confirm({ message, default: false })
147
+ }
148
+
149
+ /**
150
+ * @param {ReturnType<typeof createPiwikClient>} client
151
+ * @param {ReturnType<typeof resolvePiwikConfig>} config
152
+ * @param {import('./piwik-dimensions.mjs').LocalDimension[]} creates
153
+ */
154
+ async function applyCreates(client, config, creates) {
155
+ for (const local of creates) {
156
+ console.log(`[piwik] creating ${local.scope} ${local.name} dimension${local.slot}`)
157
+ const created =
158
+ local.scope === 'product'
159
+ ? await client.createProductDimension({
160
+ websiteId: config.websiteId,
161
+ name: local.name,
162
+ slot: local.slot,
163
+ })
164
+ : await client.createDimension({
165
+ websiteId: config.websiteId,
166
+ name: local.name,
167
+ scope: local.scope,
168
+ })
169
+ const assignedId = local.scope === 'product' ? created.slot : created.trackingId
170
+ if (assignedId !== local.slot) {
171
+ throw new PiwikDimensionError(
172
+ `Piwik assigned ${created.scope} slot ${created.slot} (tracking_id ${created.trackingId}) to "${created.name}" (id ${created.id}), but the app sends dimension${local.slot}. Stopped before the next create. Deactivating the dimension does not free the slot.`,
173
+ )
174
+ }
175
+ console.log(`[piwik] created id ${created.id} ${created.scope} slot ${created.slot} tracking_id ${created.trackingId}`)
176
+ }
177
+ }
178
+
179
+ /**
180
+ * @param {ReturnType<typeof createPiwikClient>} client
181
+ * @param {string} websiteId
182
+ * @param {{ local: import('./piwik-dimensions.mjs').LocalDimension, remote: import('./piwik-dimensions.mjs').RemoteDimension }[]} updates
183
+ */
184
+ async function applyUpdates(client, websiteId, updates) {
185
+ for (const { local, remote } of updates) {
186
+ const target = remote.websiteId ? remote : { ...remote, websiteId }
187
+ console.log(`[piwik] updating ${remote.scope} slot ${remote.slot} "${remote.name}" -> "${local.name}"`)
188
+ const updated =
189
+ remote.scope === 'product'
190
+ ? await client.updateProductDimension(target, local.name)
191
+ : await client.updateDimension(target, local.name)
192
+ console.log(`[piwik] updated id ${updated.id} name "${updated.name}"`)
193
+ }
194
+ }
195
+
196
+ async function main() {
197
+ const argv = process.argv.slice(2)
198
+ if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
199
+ printHelp()
200
+ return argv.length === 0 ? 1 : 0
201
+ }
202
+
203
+ const config = resolvePiwikConfig(argv)
204
+ if (!COMMANDS.has(config.command)) {
205
+ console.error(`[piwik] Unknown command: ${config.command}`)
206
+ printHelp()
207
+ return 1
208
+ }
209
+
210
+ if (config.command === 'list' && config.localOnly) {
211
+ printLocal(loadLocalDimensions(config))
212
+ return 0
213
+ }
214
+
215
+ if (config.command === 'compare' || config.command === 'migrate') {
216
+ return runEnvironmentCommand(config)
217
+ }
218
+
219
+ if (config.command === 'pull') {
220
+ return runPull(config)
221
+ }
222
+
223
+ assertApiConfig(config)
224
+ console.log(
225
+ `[piwik] account=${config.account} website=${config.websiteId} env=${config.envName}${config.siteSource ? ` (${config.siteSource})` : ''}`,
226
+ )
227
+ if (config.dimensionFiles.length > 0 && config.command !== 'list') {
228
+ console.log(`[piwik] local ${config.dimensionFiles.map((file) => file.replace(`${config.cwd}/`, '')).join(', ')}`)
229
+ }
230
+
231
+ const client = createPiwikClient({
232
+ account: config.account,
233
+ clientId: config.clientId,
234
+ clientSecret: config.clientSecret,
235
+ })
236
+ const remoteResult = await client.listDimensions(config.websiteId)
237
+ const remotes = config.scope
238
+ ? remoteResult.dimensions.filter((item) => item.scope === config.scope)
239
+ : remoteResult.dimensions
240
+ for (const remote of remotes) {
241
+ if (!remote.websiteId) remote.websiteId = config.websiteId
242
+ }
243
+
244
+ if (config.command === 'list') {
245
+ printRemote(remotes, remoteResult.slots)
246
+ return 0
247
+ }
248
+
249
+ const locals = loadLocalDimensions(config)
250
+ const diff = diffDimensions(locals, remotes)
251
+ const plan = planCreates(diff.missing, remotes)
252
+ console.log(formatDimensionReport(diff, plan))
253
+
254
+ const wantsCreate = config.command === 'add' || config.command === 'sync'
255
+ const wantsUpdate = config.command === 'update' || config.command === 'sync'
256
+ const drifted = diff.missing.length > 0 || diff.remoteOnly.length > 0
257
+
258
+ if (config.command === 'diff') return drifted ? 1 : 0
259
+
260
+ const createCount = wantsCreate ? plan.creates.length : 0
261
+ const updateCount = wantsUpdate ? diff.nameMismatch.length : 0
262
+ if (createCount === 0 && updateCount === 0) {
263
+ console.log('[piwik] Nothing to write.')
264
+ return plan.blocked.length > 0 && wantsCreate ? 1 : 0
265
+ }
266
+
267
+ if (config.dryRun) {
268
+ console.log('[piwik] Dry run. No dimensions were created or updated.')
269
+ return plan.blocked.length > 0 && wantsCreate ? 1 : 0
270
+ }
271
+
272
+ const apply = await confirmApply(
273
+ `Copy ${createCount} new and ${updateCount} renamed dimension(s) from the local map up to ${config.envName || config.account}?`,
274
+ config,
275
+ )
276
+ if (!apply) return 0
277
+
278
+ /** @type {unknown} */
279
+ let writeError = null
280
+ try {
281
+ if (wantsCreate) await applyCreates(client, config, plan.creates)
282
+ if (wantsUpdate) await applyUpdates(client, config.websiteId, diff.nameMismatch)
283
+ } catch (error) {
284
+ writeError = error
285
+ }
286
+
287
+ console.log('\n[piwik] Piwik after the update:')
288
+ const refreshed = await listSiteDimensions(client, config.websiteId, config.scope)
289
+ const nextDiff = diffDimensions(locals, refreshed)
290
+ const nextPlan = planCreates(nextDiff.missing, refreshed)
291
+ console.log(formatDimensionReport(nextDiff, nextPlan))
292
+ if (writeError) throw writeError
293
+
294
+ const stillMissing = wantsCreate && (nextPlan.creates.length > 0 || nextPlan.blocked.length > 0)
295
+ const stillRenamed = wantsUpdate && nextDiff.nameMismatch.length > 0
296
+ return stillMissing || stillRenamed ? 1 : 0
297
+ }
298
+
299
+ /**
300
+ * @param {ReturnType<typeof createPiwikClient>} client
301
+ * @param {string} websiteId
302
+ * @param {string} [scope]
303
+ */
304
+ /**
305
+ * Copy tracking ids from Piwik down into the local dimension map.
306
+ * @param {ReturnType<typeof resolvePiwikConfig>} config
307
+ */
308
+ async function runPull(config) {
309
+ assertApiConfig(config)
310
+ console.log(
311
+ `[piwik] account=${config.account} website=${config.websiteId} env=${config.envName}${config.siteSource ? ` (${config.siteSource})` : ''}`,
312
+ )
313
+ console.log('[piwik] Pull copies tracking ids from Piwik down into the local map. Keys match on name.')
314
+ const locals = loadLocalDimensions(config)
315
+ console.log(`[piwik] local ${config.dimensionFiles.map((file) => file.replace(`${config.cwd}/`, '')).join(', ')}`)
316
+ const client = createPiwikClient({
317
+ account: config.account,
318
+ clientId: config.clientId,
319
+ clientSecret: config.clientSecret,
320
+ })
321
+ const remotes = await listSiteDimensions(client, config.websiteId, config.scope)
322
+ const plan = planLocalPull(locals, remotes)
323
+ console.log(
324
+ `[piwik] ${plan.inSync.length} in sync, ${plan.updates.length} to update in the repo, ${plan.missingInPiwik.length} missing in Piwik, ${plan.remoteOnly.length} only in Piwik`,
325
+ )
326
+ if (plan.updates.length > 0) {
327
+ console.log(
328
+ renderTable(
329
+ ['scope', 'key', 'in repo', 'tracking_id in Piwik'],
330
+ plan.updates.map((item) => [
331
+ item.local.scope,
332
+ item.local.key,
333
+ String(item.local.slot),
334
+ String(item.next),
335
+ ]),
336
+ ).join('\n'),
337
+ )
338
+ }
339
+ if (plan.updates.length === 0) {
340
+ console.log('[piwik] Nothing to write.')
341
+ return 0
342
+ }
343
+ if (config.dryRun) {
344
+ console.log('[piwik] Dry run. The local dimension map was not changed.')
345
+ return 0
346
+ }
347
+ const apply = await confirmApply(
348
+ `Write ${plan.updates.length} tracking id(s) from Piwik into the local dimension map?`,
349
+ config,
350
+ )
351
+ if (!apply) return 0
352
+
353
+ /** @type {Map<string, { key: string, from: number, to: number }[]>} */
354
+ const byFile = new Map()
355
+ for (const item of plan.updates) {
356
+ const list = byFile.get(item.local.file) ?? []
357
+ list.push({ key: item.local.key, from: item.local.slot, to: item.next })
358
+ byFile.set(item.local.file, list)
359
+ }
360
+ for (const [file, updates] of byFile) {
361
+ const absolute = file.startsWith('/') ? file : `${config.cwd}/${file}`
362
+ const source = fs.readFileSync(absolute, 'utf8')
363
+ fs.writeFileSync(absolute, rewriteDimensionNumbers(source, updates))
364
+ console.log(`[piwik] updated ${file.replace(`${config.cwd}/`, '')}`)
365
+ }
366
+ return 0
367
+ }
368
+
369
+ async function listSiteDimensions(client, websiteId, scope) {
370
+ const result = await client.listDimensions(websiteId)
371
+ const dimensions = scope ? result.dimensions.filter((item) => item.scope === scope) : result.dimensions
372
+ for (const remote of dimensions) {
373
+ if (!remote.websiteId) remote.websiteId = websiteId
374
+ }
375
+ return dimensions
376
+ }
377
+
378
+ /**
379
+ * @param {ReturnType<typeof resolvePiwikConfig>} config
380
+ */
381
+ async function runEnvironmentCommand(config) {
382
+ const { from, to } = resolveEnvironmentPair(config.cwd, config.fromEnv, config.toEnv)
383
+ assertApiConfig({ ...config, account: from.account, websiteId: from.websiteId })
384
+ if (from.account !== to.account) {
385
+ throw new PiwikDimensionError(
386
+ `${from.environment} uses account ${from.account} and ${to.environment} uses ${to.account}. Compare and migrate need the same Piwik account.`,
387
+ )
388
+ }
389
+
390
+ console.log(`[piwik] from=${from.environment} website=${from.websiteId}`)
391
+ console.log(`[piwik] to=${to.environment} website=${to.websiteId}`)
392
+
393
+ const client = createPiwikClient({
394
+ account: from.account,
395
+ clientId: config.clientId,
396
+ clientSecret: config.clientSecret,
397
+ })
398
+ const source = await listSiteDimensions(client, from.websiteId, config.scope)
399
+ const target = await listSiteDimensions(client, to.websiteId, config.scope)
400
+ const diff = diffEnvironments(source, target)
401
+ const plan = planEnvironmentMigration(diff, target)
402
+ console.log(formatEnvironmentReport(diff, plan, from.environment, to.environment))
403
+
404
+ const drifted = diff.changed.length > 0 || diff.missing.length > 0 || diff.targetOnly.length > 0
405
+ if (config.command === 'compare') return drifted ? 1 : 0
406
+
407
+ if (diff.targetOnly.length > 0) {
408
+ console.log(`[piwik] ${diff.targetOnly.length} dimension(s) exist only in ${to.environment} and are left as they are.`)
409
+ }
410
+ if (plan.creates.length === 0 && diff.changed.length === 0) {
411
+ console.log('[piwik] Nothing to write.')
412
+ return plan.blocked.length > 0 ? 1 : 0
413
+ }
414
+ if (config.dryRun) {
415
+ console.log('[piwik] Dry run. No dimensions were created or updated.')
416
+ return plan.blocked.length > 0 ? 1 : 0
417
+ }
418
+
419
+ const apply = await confirmApply(
420
+ `Copy ${plan.creates.length} new and ${diff.changed.length} updated dimension(s) from ${from.environment} to ${to.environment}?`,
421
+ config,
422
+ )
423
+ if (!apply) return 0
424
+
425
+ /** @type {unknown} */
426
+ let writeError = null
427
+ try {
428
+ for (const { source: origin, target: current } of diff.changed) {
429
+ console.log(
430
+ `[piwik] updating ${current.scope} tracking_id ${current.trackingId} "${current.name}" -> "${origin.name}"`,
431
+ )
432
+ const updated =
433
+ current.scope === 'product'
434
+ ? await client.updateProductDimensionState(current, { name: origin.name, description: origin.description })
435
+ : await client.updateDimensionState(current, {
436
+ name: origin.name,
437
+ description: origin.description,
438
+ active: origin.active,
439
+ })
440
+ console.log(`[piwik] updated id ${updated.id} name "${updated.name}"`)
441
+ }
442
+
443
+ for (const local of plan.creates) {
444
+ const origin = source.find(
445
+ (item) =>
446
+ item.scope === local.scope && (item.scope === 'product' ? item.slot === local.slot : item.trackingId === local.slot),
447
+ )
448
+ if (!origin) {
449
+ throw new PiwikDimensionError(`Missing source dimension for ${local.scope} ${local.slot}.`)
450
+ }
451
+ console.log(`[piwik] creating ${origin.scope} ${origin.name} tracking_id ${origin.trackingId} on ${to.environment}`)
452
+ const created =
453
+ origin.scope === 'product'
454
+ ? await client.createProductDimension({
455
+ websiteId: to.websiteId,
456
+ name: origin.name,
457
+ slot: origin.slot,
458
+ description: origin.description,
459
+ })
460
+ : await client.createDimension({
461
+ websiteId: to.websiteId,
462
+ name: origin.name,
463
+ scope: origin.scope,
464
+ description: origin.description,
465
+ active: origin.active,
466
+ })
467
+ const assignedId = origin.scope === 'product' ? created.slot : created.trackingId
468
+ const expectedId = origin.scope === 'product' ? origin.slot : origin.trackingId
469
+ if (assignedId !== expectedId) {
470
+ throw new PiwikDimensionError(
471
+ `Piwik assigned ${created.scope} slot ${created.slot} (tracking_id ${created.trackingId}) to "${created.name}" on ${to.environment}, but ${from.environment} uses tracking_id ${expectedId}. Stopped before the next create.`,
472
+ )
473
+ }
474
+ console.log(`[piwik] created id ${created.id} ${created.scope} slot ${created.slot} tracking_id ${created.trackingId}`)
475
+ }
476
+ } catch (error) {
477
+ writeError = error
478
+ }
479
+
480
+ console.log('\n[piwik] Piwik after the update:')
481
+ const refreshedSource = await listSiteDimensions(client, from.websiteId, config.scope)
482
+ const refreshedTarget = await listSiteDimensions(client, to.websiteId, config.scope)
483
+ const nextDiff = diffEnvironments(refreshedSource, refreshedTarget)
484
+ const nextPlan = planEnvironmentMigration(nextDiff, refreshedTarget)
485
+ console.log(formatEnvironmentReport(nextDiff, nextPlan, from.environment, to.environment))
486
+ if (writeError) throw writeError
487
+
488
+ return nextPlan.creates.length > 0 || nextPlan.blocked.length > 0 || nextDiff.changed.length > 0 ? 1 : 0
489
+ }
490
+
491
+ main()
492
+ .then((code) => {
493
+ process.exitCode = code ?? 0
494
+ })
495
+ .catch((error) => {
496
+ const message = error instanceof Error ? error.message : String(error)
497
+ console.error(`[piwik] ${message}`)
498
+ if (!(error instanceof PiwikDimensionError) && error instanceof Error && error.stack) {
499
+ console.error(error.stack)
500
+ }
501
+ process.exitCode = 1
502
+ })