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,653 @@
1
+ /**
2
+ * Piwik PRO management API client and consumer config discovery.
3
+ *
4
+ * Auth is OAuth2 client credentials against https://{account}.piwik.pro/auth/token.
5
+ * Client id and secret come from the environment or a local env file, never from
6
+ * piwik/.piwik.json.
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+
12
+ import { getFlagValue, parseFlags } from './poeditor-common.mjs'
13
+ import {
14
+ PiwikDimensionError,
15
+ applyDesiredNames,
16
+ parseDimensionSource,
17
+ } from './piwik-dimensions.mjs'
18
+
19
+ export const JSON_API = 'application/vnd.api+json'
20
+
21
+ export const DIMENSION_CANDIDATES = [
22
+ 'src/analytics/dimensions.ts',
23
+ 'src/util/piwikDimensions.ts',
24
+ ]
25
+
26
+ const ENV_NAMES = ['production', 'preprod', 'development', 'localhost']
27
+
28
+ /**
29
+ * @param {string} filePath
30
+ * @param {string} key
31
+ */
32
+ function readEnvValueFromFile(filePath, key) {
33
+ if (!fs.existsSync(filePath)) return ''
34
+ const raw = fs.readFileSync(filePath, 'utf8')
35
+ for (const line of raw.split(/\r?\n/)) {
36
+ const trimmed = line.trim()
37
+ if (!trimmed || trimmed.startsWith('#')) continue
38
+ const noExport = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed
39
+ const eqIdx = noExport.indexOf('=')
40
+ if (eqIdx <= 0) continue
41
+ if (noExport.slice(0, eqIdx).trim() !== key) continue
42
+ let value = noExport.slice(eqIdx + 1).trim()
43
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
44
+ value = value.slice(1, -1)
45
+ } else {
46
+ const hashIdx = value.indexOf(' #')
47
+ if (hashIdx >= 0) value = value.slice(0, hashIdx).trim()
48
+ }
49
+ return value
50
+ }
51
+ return ''
52
+ }
53
+
54
+ /**
55
+ * @param {string} key
56
+ * @param {string} cwd
57
+ */
58
+ function readSecret(key, cwd) {
59
+ if (process.env[key]) return process.env[key]
60
+ for (const file of ['env.local', '.env.local', '.env']) {
61
+ const value = readEnvValueFromFile(path.join(cwd, file), key)
62
+ if (value) return value
63
+ }
64
+ return ''
65
+ }
66
+
67
+ /**
68
+ * @param {string} url
69
+ */
70
+ export function accountFromPiwikUrl(url) {
71
+ let hostname = ''
72
+ try {
73
+ hostname = new URL(url).hostname
74
+ } catch {
75
+ throw new PiwikDimensionError(`Invalid Piwik URL: ${url}`)
76
+ }
77
+ const account = hostname.split('.')[0]
78
+ if (!account || !hostname.endsWith('.piwik.pro')) {
79
+ throw new PiwikDimensionError(`Expected a https://{account}.piwik.pro URL, got ${url}`)
80
+ }
81
+ return account
82
+ }
83
+
84
+ /**
85
+ * @param {string} source
86
+ * @param {number} openIndex
87
+ */
88
+ function readBrace(source, openIndex) {
89
+ let depth = 0
90
+ for (let i = openIndex; i < source.length; i += 1) {
91
+ if (source[i] === '{') depth += 1
92
+ else if (source[i] === '}') {
93
+ depth -= 1
94
+ if (depth === 0) return source.slice(openIndex + 1, i)
95
+ }
96
+ }
97
+ return ''
98
+ }
99
+
100
+ /**
101
+ * Find piwikUrl/piwikId (or piwikProBaseUrl/piwikProId) on each environment object.
102
+ * @param {string} source
103
+ */
104
+ export function discoverPiwikSites(source) {
105
+ /** @type {{ environment: string, account: string, websiteId: string, url: string }[]} */
106
+ const sites = []
107
+ const re = /const\s+([A-Za-z0-9_]+)\s*(?::[^=]+)?=\s*\{/g
108
+ let match = re.exec(source)
109
+ while (match) {
110
+ const constName = match[1]
111
+ const braceAt = source.indexOf('{', match.index)
112
+ const body = readBrace(source, braceAt)
113
+ const environmentMatch = body.match(/environment:\s*['"]([A-Za-z0-9_:-]+)['"]/)
114
+ const environment = environmentMatch?.[1] ?? (ENV_NAMES.includes(constName) ? constName : '')
115
+ const url = body.match(/piwik(?:ProBase)?Url:\s*['"]([^'"]+)['"]/)?.[1] ?? ''
116
+ const websiteId = body.match(/piwik(?:Pro)?Id:\s*['"]([^'"]+)['"]/)?.[1] ?? ''
117
+ if (environment && url && websiteId) {
118
+ sites.push({
119
+ environment,
120
+ account: accountFromPiwikUrl(url),
121
+ websiteId,
122
+ url,
123
+ })
124
+ }
125
+ match = re.exec(source)
126
+ }
127
+ return sites
128
+ }
129
+
130
+ /** Prefer these names when several environments share one piwik id. */
131
+ const ENV_LABEL_PREFERENCE = ['preprod', 'staging', 'production', 'demo', 'development', 'localhost', 'local']
132
+
133
+ /**
134
+ * One entry per piwik id. localhost/development/preprod that share an id become a single "preprod" choice.
135
+ * @param {{ environment: string, account: string, websiteId: string, url?: string }[]} sites
136
+ */
137
+ export function uniquePiwikEnvironments(sites) {
138
+ /** @type {Map<string, { environment: string, account: string, websiteId: string }[]>} */
139
+ const groups = new Map()
140
+ for (const site of sites) {
141
+ const group = groups.get(site.websiteId) ?? []
142
+ group.push(site)
143
+ groups.set(site.websiteId, group)
144
+ }
145
+
146
+ const unique = [...groups.values()].map((group) => {
147
+ const preferred =
148
+ ENV_LABEL_PREFERENCE.map((name) => group.find((site) => site.environment === name)).find(Boolean) ?? group[0]
149
+ return {
150
+ environment: preferred.environment,
151
+ account: preferred.account,
152
+ websiteId: preferred.websiteId,
153
+ }
154
+ })
155
+
156
+ const rank = (name) => {
157
+ const index = ENV_LABEL_PREFERENCE.indexOf(name)
158
+ return index === -1 ? ENV_LABEL_PREFERENCE.length : index
159
+ }
160
+ unique.sort((a, b) => rank(a.environment) - rank(b.environment))
161
+ return unique
162
+ }
163
+
164
+ /**
165
+ * Default site is the first non-production environment. Production is used only when it is the only site.
166
+ * @param {{ environment: string }[]} environments
167
+ */
168
+ export function defaultPiwikEnvironment(environments) {
169
+ const preferred = environments.find((item) => item.environment !== 'production')
170
+ return preferred?.environment || environments[0]?.environment || ''
171
+ }
172
+
173
+ /**
174
+ * Compare and migrate from the first non-production site to production when that site exists.
175
+ * @param {{ environment: string }[]} environments
176
+ */
177
+ export function defaultEnvironmentPair(environments) {
178
+ const from = defaultPiwikEnvironment(environments)
179
+ const to =
180
+ environments.find((item) => item.environment === 'production' && item.environment !== from)?.environment ||
181
+ environments.find((item) => item.environment !== from)?.environment ||
182
+ ''
183
+ return { from, to }
184
+ }
185
+
186
+ /**
187
+ * @param {string} cwd
188
+ * @param {string} fromName
189
+ * @param {string} toName
190
+ */
191
+ export function resolveEnvironmentPair(cwd, fromName, toName) {
192
+ const environments = listPiwikEnvironments(cwd)
193
+ if (environments.length < 2) {
194
+ throw new PiwikDimensionError('Need two Piwik sites in src/config/env.ts to compare environments.')
195
+ }
196
+ const defaults = defaultEnvironmentPair(environments)
197
+ const from = environments.find((item) => item.environment === (fromName || defaults.from))
198
+ const to = environments.find((item) => item.environment === (toName || defaults.to))
199
+ const known = environments.map((item) => item.environment).join(', ')
200
+ if (!from) {
201
+ throw new PiwikDimensionError(`No Piwik site named "${fromName || defaults.from}". Found: ${known}.`)
202
+ }
203
+ if (!to) {
204
+ throw new PiwikDimensionError(`No Piwik site named "${toName || defaults.to}". Found: ${known}.`)
205
+ }
206
+ if (from.websiteId === to.websiteId) {
207
+ throw new PiwikDimensionError(`${from.environment} and ${to.environment} use the same Piwik site.`)
208
+ }
209
+ return { from, to }
210
+ }
211
+
212
+ /**
213
+ * @param {string} cwd
214
+ */
215
+ export function listPiwikEnvironments(cwd) {
216
+ const envFile = path.join(cwd, 'src/config/env.ts')
217
+ if (!fs.existsSync(envFile)) return []
218
+ return uniquePiwikEnvironments(discoverPiwikSites(fs.readFileSync(envFile, 'utf8')))
219
+ }
220
+
221
+ /**
222
+ * @param {string} cwd
223
+ * @param {string} [relativePath]
224
+ */
225
+ function readJson(cwd, relativePath) {
226
+ const filePath = path.resolve(cwd, relativePath ?? 'piwik/.piwik.json')
227
+ if (!fs.existsSync(filePath)) return { filePath, json: null }
228
+ try {
229
+ return { filePath, json: JSON.parse(fs.readFileSync(filePath, 'utf8')) }
230
+ } catch (error) {
231
+ const message = error instanceof Error ? error.message : String(error)
232
+ throw new PiwikDimensionError(`Could not parse ${filePath}: ${message}`)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * @param {string[]} argv
238
+ * @param {string} [cwd]
239
+ */
240
+ export function resolvePiwikConfig(argv, cwd = process.cwd()) {
241
+ const { positional, flags } = parseFlags(argv)
242
+ const command = positional[0] ?? ''
243
+ const configArg = getFlagValue(flags, 'config')
244
+ const { filePath: configPath, json } = readJson(cwd, typeof configArg === 'string' ? configArg : undefined)
245
+ const config = json && typeof json === 'object' ? json : {}
246
+
247
+ let envName = String(getFlagValue(flags, 'env', typeof config.environment === 'string' ? config.environment : ''))
248
+ const dimensionsFlag = getFlagValue(flags, 'dimensions')
249
+ const configuredFile = typeof config.dimensionsFile === 'string' ? config.dimensionsFile : ''
250
+ const dimensionFiles = resolveDimensionFiles(cwd, dimensionsFlag, configuredFile)
251
+
252
+ let account = String(getFlagValue(flags, 'account', config.account ?? process.env.PIWIK_ACCOUNT ?? ''))
253
+ let websiteId = String(getFlagValue(flags, 'website-id', config.websiteId ?? process.env.PIWIK_WEBSITE_ID ?? ''))
254
+ let siteSource = websiteId ? 'flag, config, or PIWIK_WEBSITE_ID' : ''
255
+
256
+ if (!websiteId || !account) {
257
+ const envFile = path.join(cwd, 'src/config/env.ts')
258
+ if (fs.existsSync(envFile)) {
259
+ const sites = discoverPiwikSites(fs.readFileSync(envFile, 'utf8'))
260
+ if (!envName) envName = defaultPiwikEnvironment(uniquePiwikEnvironments(sites))
261
+ const site = sites.find((item) => item.environment === envName)
262
+ if (!site && (!websiteId || !account)) {
263
+ const known = sites.map((item) => item.environment).join(', ') || 'none'
264
+ throw new PiwikDimensionError(
265
+ `No Piwik site for environment "${envName}" in src/config/env.ts. Found: ${known}. Pass --website-id and --account, or --env.`,
266
+ )
267
+ }
268
+ if (site) {
269
+ if (!account) account = site.account
270
+ if (!websiteId) {
271
+ websiteId = site.websiteId
272
+ siteSource = `src/config/env.ts (${site.environment})`
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ const clientId = String(getFlagValue(flags, 'client-id', readSecret('PIWIK_CLIENT_ID', cwd)))
279
+ const clientSecret = String(getFlagValue(flags, 'client-secret', readSecret('PIWIK_CLIENT_SECRET', cwd)))
280
+ const names = config.names && typeof config.names === 'object' ? config.names : undefined
281
+ const scope = String(getFlagValue(flags, 'scope', ''))
282
+ if (scope && scope !== 'event' && scope !== 'session' && scope !== 'product') {
283
+ throw new PiwikDimensionError(`Unknown --scope ${scope}. Use event, session, or product.`)
284
+ }
285
+
286
+ return {
287
+ command,
288
+ cwd,
289
+ configPath: json ? configPath : '',
290
+ envName,
291
+ account,
292
+ websiteId,
293
+ siteSource,
294
+ clientId,
295
+ clientSecret,
296
+ dimensionFiles,
297
+ names,
298
+ scope,
299
+ yes: getFlagValue(flags, 'yes') === 'true' || flags.has('yes'),
300
+ dryRun: getFlagValue(flags, 'dry-run') === 'true' || flags.has('dry-run'),
301
+ localOnly: getFlagValue(flags, 'local') === 'true' || flags.has('local'),
302
+ fromEnv: String(getFlagValue(flags, 'from', '')),
303
+ toEnv: String(getFlagValue(flags, 'to', '')),
304
+ }
305
+ }
306
+
307
+ /**
308
+ * @param {string} cwd
309
+ * @param {string | undefined} flag
310
+ * @param {string} configuredFile
311
+ */
312
+ function resolveDimensionFiles(cwd, flag, configuredFile) {
313
+ if (typeof flag === 'string' && flag.trim() !== '') {
314
+ return [path.resolve(cwd, flag)]
315
+ }
316
+ if (configuredFile.trim() !== '') {
317
+ return [path.resolve(cwd, configuredFile)]
318
+ }
319
+ return DIMENSION_CANDIDATES.map((relative) => path.join(cwd, relative)).filter((file) => fs.existsSync(file))
320
+ }
321
+
322
+ /**
323
+ * @param {ReturnType<typeof resolvePiwikConfig>} config
324
+ */
325
+ export function loadLocalDimensions(config) {
326
+ if (config.dimensionFiles.length === 0) {
327
+ throw new PiwikDimensionError(
328
+ 'No local dimension file found. Expected src/analytics/dimensions.ts or src/util/piwikDimensions.ts, or pass --dimensions.',
329
+ )
330
+ }
331
+ const missing = config.dimensionFiles.filter((file) => !fs.existsSync(file))
332
+ if (missing.length > 0) {
333
+ throw new PiwikDimensionError(`Dimension file not found: ${missing.join(', ')}`)
334
+ }
335
+
336
+ const locals = config.dimensionFiles.flatMap((file) =>
337
+ parseDimensionSource(fs.readFileSync(file, 'utf8'), path.relative(config.cwd, file)),
338
+ )
339
+ const named = applyDesiredNames(locals, config.names)
340
+ const filtered = config.scope ? named.filter((item) => item.scope === config.scope) : named
341
+
342
+ const seen = new Set()
343
+ for (const item of filtered) {
344
+ const key = `${item.scope}:${item.slot}`
345
+ if (seen.has(key)) {
346
+ throw new PiwikDimensionError(`Duplicate ${item.scope} slot ${item.slot} across local dimension files.`)
347
+ }
348
+ seen.add(key)
349
+ }
350
+ return filtered
351
+ }
352
+
353
+ /**
354
+ * @param {number} status
355
+ * @param {unknown} body
356
+ */
357
+ function formatApiError(status, body) {
358
+ if (body && typeof body === 'object' && Array.isArray(/** @type {{ errors?: unknown }} */ (body).errors)) {
359
+ const details = /** @type {{ errors: { detail?: string, title?: string }[] }} */ (body).errors
360
+ .map((error) => error.detail || error.title || '')
361
+ .filter(Boolean)
362
+ if (details.length > 0) return `Piwik API ${status}: ${details.join('; ')}`
363
+ }
364
+ const text = typeof body === 'string' ? body : JSON.stringify(body)
365
+ return `Piwik API ${status}: ${text}`
366
+ }
367
+
368
+ /**
369
+ * @param {{ account: string, clientId: string, clientSecret: string, fetchImpl?: typeof fetch }} options
370
+ */
371
+ export function createPiwikClient(options) {
372
+ const fetchImpl = options.fetchImpl ?? fetch
373
+ const base = `https://${options.account}.piwik.pro`
374
+ /** @type {{ token: string, expiresAt: number }} */
375
+ const cache = { token: '', expiresAt: 0 }
376
+
377
+ async function accessToken() {
378
+ if (cache.token && Date.now() < cache.expiresAt) return cache.token
379
+ const response = await fetchImpl(`${base}/auth/token`, {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
382
+ body: JSON.stringify({
383
+ grant_type: 'client_credentials',
384
+ client_id: options.clientId,
385
+ client_secret: options.clientSecret,
386
+ }),
387
+ })
388
+ const body = await response.json().catch(() => ({}))
389
+ if (!response.ok) {
390
+ throw new PiwikDimensionError(formatApiError(response.status, body))
391
+ }
392
+ const token = body?.access_token
393
+ if (typeof token !== 'string' || token === '') {
394
+ throw new PiwikDimensionError('Piwik token response did not include access_token.')
395
+ }
396
+ const expiresIn = Number(body.expires_in ?? 1800)
397
+ cache.token = token
398
+ cache.expiresAt = Date.now() + Math.max(30, expiresIn - 30) * 1000
399
+ return token
400
+ }
401
+
402
+ /**
403
+ * @param {string} method
404
+ * @param {string} pathname
405
+ * @param {{ query?: Record<string, string | number>, body?: unknown }} [extra]
406
+ */
407
+ async function request(method, pathname, extra = {}) {
408
+ const token = await accessToken()
409
+ const url = new URL(pathname, base)
410
+ for (const [key, value] of Object.entries(extra.query ?? {})) {
411
+ url.searchParams.set(key, String(value))
412
+ }
413
+ const response = await fetchImpl(url, {
414
+ method,
415
+ headers: {
416
+ Authorization: `Bearer ${token}`,
417
+ Accept: JSON_API,
418
+ ...(extra.body ? { 'Content-Type': JSON_API } : {}),
419
+ },
420
+ body: extra.body ? JSON.stringify(extra.body) : undefined,
421
+ })
422
+ const text = await response.text()
423
+ /** @type {unknown} */
424
+ let body = {}
425
+ if (text) {
426
+ try {
427
+ body = JSON.parse(text)
428
+ } catch {
429
+ body = text
430
+ }
431
+ }
432
+ if (!response.ok) {
433
+ throw new PiwikDimensionError(formatApiError(response.status, body))
434
+ }
435
+ return body
436
+ }
437
+
438
+ /**
439
+ * @param {string} pathname
440
+ * @param {string} websiteId
441
+ */
442
+ async function listAll(pathname, websiteId) {
443
+ /** @type {unknown[]} */
444
+ const rows = []
445
+ let offset = 0
446
+ const limit = 1000
447
+ while (true) {
448
+ const page = await request('GET', pathname, { query: { website_id: websiteId, limit, offset } })
449
+ const data = Array.isArray(page?.data) ? page.data : []
450
+ rows.push(...data)
451
+ const total = Number(page?.meta?.total ?? rows.length)
452
+ offset += data.length
453
+ if (data.length === 0 || rows.length >= total) break
454
+ }
455
+ return rows
456
+ }
457
+
458
+ return {
459
+ /**
460
+ * @param {string} websiteId
461
+ */
462
+ async listDimensions(websiteId) {
463
+ const [custom, product, slots] = await Promise.all([
464
+ listAll('/api/analytics/v1/manage/custom-dimensions/', websiteId),
465
+ listAll('/api/analytics/v1/manage/product-custom-dimensions/', websiteId).catch((error) => {
466
+ const message = error instanceof Error ? error.message : String(error)
467
+ if (/Piwik API (400|403|404)/.test(message)) return []
468
+ throw error
469
+ }),
470
+ request('GET', '/api/analytics/v1/manage/custom-dimensions/slots/', { query: { website_id: websiteId } }),
471
+ ])
472
+ return {
473
+ dimensions: [
474
+ ...custom.map((row) => normalizeDimension(row, 'event')),
475
+ ...product.map((row) => normalizeDimension(row, 'product')),
476
+ ],
477
+ slots: slots?.data?.attributes ?? {},
478
+ }
479
+ },
480
+ /**
481
+ * @param {{ websiteId: string, name: string, scope: 'event' | 'session', description?: string, active?: boolean }} input
482
+ */
483
+ async createDimension(input) {
484
+ const body = await request('POST', '/api/analytics/v1/manage/custom-dimensions/', {
485
+ body: {
486
+ data: {
487
+ type: 'CustomDimension',
488
+ attributes: {
489
+ website_id: input.websiteId,
490
+ name: input.name,
491
+ active: input.active !== false,
492
+ case_sensitive: false,
493
+ scope: input.scope,
494
+ ...(input.description ? { description: input.description } : {}),
495
+ },
496
+ },
497
+ },
498
+ })
499
+ return normalizeDimension(body?.data, input.scope)
500
+ },
501
+ /**
502
+ * @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
503
+ * @param {string} name
504
+ */
505
+ async updateDimension(remote, name) {
506
+ const body = await request('PUT', `/api/analytics/v1/manage/custom-dimensions/${remote.id}/`, {
507
+ body: {
508
+ data: {
509
+ type: 'CustomDimension',
510
+ id: remote.id,
511
+ attributes: {
512
+ website_id: remote.websiteId,
513
+ name,
514
+ active: remote.active,
515
+ case_sensitive: remote.caseSensitive,
516
+ scope: remote.scope,
517
+ ...(remote.description ? { description: remote.description } : {}),
518
+ },
519
+ },
520
+ },
521
+ })
522
+ return normalizeDimension(body?.data, remote.scope)
523
+ },
524
+ /**
525
+ * @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
526
+ * @param {{ name: string, description: string, active: boolean }} desired
527
+ */
528
+ async updateDimensionState(remote, desired) {
529
+ const body = await request('PUT', `/api/analytics/v1/manage/custom-dimensions/${remote.id}/`, {
530
+ body: {
531
+ data: {
532
+ type: 'CustomDimension',
533
+ id: remote.id,
534
+ attributes: {
535
+ website_id: remote.websiteId,
536
+ name: desired.name,
537
+ active: desired.active,
538
+ case_sensitive: remote.caseSensitive,
539
+ scope: remote.scope,
540
+ description: desired.description,
541
+ },
542
+ },
543
+ },
544
+ })
545
+ return normalizeDimension(body?.data, remote.scope)
546
+ },
547
+ /**
548
+ * @param {{ websiteId: string, name: string, slot: number, description?: string }} input
549
+ */
550
+ async createProductDimension(input) {
551
+ const body = await request('POST', '/api/analytics/v1/manage/product-custom-dimensions/', {
552
+ body: {
553
+ data: {
554
+ type: 'ProductCustomDimension',
555
+ attributes: {
556
+ website_id: input.websiteId,
557
+ name: input.name,
558
+ slot: input.slot,
559
+ ...(input.description ? { description: input.description } : {}),
560
+ },
561
+ },
562
+ },
563
+ })
564
+ return normalizeDimension(body?.data, 'product')
565
+ },
566
+ /**
567
+ * @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
568
+ * @param {string} name
569
+ */
570
+ async updateProductDimension(remote, name) {
571
+ const body = await request('PUT', `/api/analytics/v1/manage/product-custom-dimensions/${remote.id}/`, {
572
+ body: {
573
+ data: {
574
+ type: 'ProductCustomDimension',
575
+ id: remote.id,
576
+ attributes: {
577
+ website_id: remote.websiteId,
578
+ name,
579
+ ...(remote.description ? { description: remote.description } : {}),
580
+ },
581
+ },
582
+ },
583
+ })
584
+ return normalizeDimension(body?.data, 'product')
585
+ },
586
+ /**
587
+ * @param {import('./piwik-dimensions.mjs').RemoteDimension} remote
588
+ * @param {{ name: string, description: string }} desired
589
+ */
590
+ async updateProductDimensionState(remote, desired) {
591
+ const body = await request('PUT', `/api/analytics/v1/manage/product-custom-dimensions/${remote.id}/`, {
592
+ body: {
593
+ data: {
594
+ type: 'ProductCustomDimension',
595
+ id: remote.id,
596
+ attributes: {
597
+ website_id: remote.websiteId,
598
+ name: desired.name,
599
+ description: desired.description,
600
+ },
601
+ },
602
+ },
603
+ })
604
+ return normalizeDimension(body?.data, 'product')
605
+ },
606
+ }
607
+ }
608
+
609
+ /**
610
+ * @param {unknown} row
611
+ * @param {'event' | 'session' | 'product'} scopeFallback
612
+ * @returns {import('./piwik-dimensions.mjs').RemoteDimension & { websiteId: string }}
613
+ */
614
+ export function normalizeDimension(row, scopeFallback) {
615
+ const record = row && typeof row === 'object' ? /** @type {Record<string, unknown>} */ (row) : {}
616
+ const attributes =
617
+ record.attributes && typeof record.attributes === 'object'
618
+ ? /** @type {Record<string, unknown>} */ (record.attributes)
619
+ : {}
620
+ const scope = attributes.scope === 'session' || attributes.scope === 'event' || attributes.scope === 'product'
621
+ ? attributes.scope
622
+ : scopeFallback
623
+ const slot = Number(attributes.slot)
624
+ const trackingId = Number(attributes.tracking_id ?? attributes.slot)
625
+ return {
626
+ id: String(record.id ?? ''),
627
+ name: String(attributes.name ?? ''),
628
+ slot,
629
+ trackingId,
630
+ scope,
631
+ active: attributes.active !== false,
632
+ caseSensitive: Boolean(attributes.case_sensitive),
633
+ description: typeof attributes.description === 'string' ? attributes.description : '',
634
+ websiteId: String(attributes.website_id ?? ''),
635
+ }
636
+ }
637
+
638
+ /**
639
+ * @param {ReturnType<typeof resolvePiwikConfig>} config
640
+ */
641
+ export function assertApiConfig(config) {
642
+ if (!config.account) {
643
+ throw new PiwikDimensionError('Missing Piwik account. Pass --account or set it in src/config/env.ts / piwik/.piwik.json.')
644
+ }
645
+ if (!config.websiteId) {
646
+ throw new PiwikDimensionError('Missing website id. Pass --website-id or set piwikId in src/config/env.ts.')
647
+ }
648
+ if (!config.clientId || !config.clientSecret) {
649
+ throw new PiwikDimensionError(
650
+ 'Missing Piwik API credentials. Set PIWIK_CLIENT_ID and PIWIK_CLIENT_SECRET in the environment, .env.local, or env.local.',
651
+ )
652
+ }
653
+ }