@contentful/experience-design-system-cli 2.24.0 → 2.24.1-dev-build-58e271c.0

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.
Files changed (38) hide show
  1. package/README.md +23 -0
  2. package/dist/package.json +2 -1
  3. package/dist/src/analytics/apply.d.ts +6 -0
  4. package/dist/src/analytics/apply.js +30 -0
  5. package/dist/src/analytics/client.d.ts +7 -0
  6. package/dist/src/analytics/client.js +68 -0
  7. package/dist/src/analytics/constants.d.ts +4 -0
  8. package/dist/src/analytics/constants.js +4 -0
  9. package/dist/src/analytics/env.d.ts +4 -0
  10. package/dist/src/analytics/env.js +14 -0
  11. package/dist/src/analytics/exit.d.ts +5 -0
  12. package/dist/src/analytics/exit.js +24 -0
  13. package/dist/src/analytics/index.d.ts +10 -0
  14. package/dist/src/analytics/index.js +9 -0
  15. package/dist/src/analytics/normalize.d.ts +3 -0
  16. package/dist/src/analytics/normalize.js +18 -0
  17. package/dist/src/analytics/os.d.ts +2 -0
  18. package/dist/src/analytics/os.js +13 -0
  19. package/dist/src/analytics/session.d.ts +3 -0
  20. package/dist/src/analytics/session.js +15 -0
  21. package/dist/src/analytics/tracker.d.ts +17 -0
  22. package/dist/src/analytics/tracker.js +126 -0
  23. package/dist/src/analytics/types.d.ts +28 -0
  24. package/dist/src/analytics/types.js +1 -0
  25. package/dist/src/analyze/command.js +8 -2
  26. package/dist/src/analyze/select/command.js +13 -9
  27. package/dist/src/analyze/select-agent/command.js +14 -11
  28. package/dist/src/apply/api-client.d.ts +3 -0
  29. package/dist/src/apply/api-client.js +14 -1
  30. package/dist/src/apply/command.d.ts +1 -1
  31. package/dist/src/apply/command.js +71 -50
  32. package/dist/src/generate/command.js +15 -9
  33. package/dist/src/generate/edit/command.js +1 -0
  34. package/dist/src/import/orchestrator.js +18 -12
  35. package/dist/src/index.js +6 -1
  36. package/dist/src/print/command.js +16 -13
  37. package/dist/src/program.js +8 -1
  38. package/package.json +6 -5
@@ -11,6 +11,7 @@ import { buildRepoContextIndex, buildSelectionContext } from './context-builder.
11
11
  import { runShowRationale } from './show-rationale.js';
12
12
  import { isAbsolute, resolve } from 'node:path';
13
13
  import { getDebugLogger } from '../../lib/debug-logger.js';
14
+ import { bindAnalyticsSessionId, enrichCommandResult, exitWithAnalytics } from '../../analytics/index.js';
14
15
  import { validateExtractedComponents, shouldExcludeDueToValidation, formatExclusionWarning, } from '@contentful/experience-design-system-extraction';
15
16
  const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
16
17
  export const DEFAULT_CONCURRENCY = 10;
@@ -27,7 +28,7 @@ function resolveBatchSize() {
27
28
  return DEFAULT_BATCH_SIZE;
28
29
  return Math.floor(n);
29
30
  }
30
- function resolveSessionId(sessionFlag) {
31
+ async function resolveSessionId(sessionFlag) {
31
32
  if (sessionFlag)
32
33
  return sessionFlag;
33
34
  const db = openPipelineDb();
@@ -42,7 +43,7 @@ function resolveSessionId(sessionFlag) {
42
43
  .get();
43
44
  if (!row) {
44
45
  process.stderr.write('Error: no completed analyze extract session found. Run analyze extract first, or pass --session <id>.\n');
45
- process.exit(1);
46
+ return await exitWithAnalytics(1);
46
47
  }
47
48
  return row.id;
48
49
  }
@@ -329,7 +330,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
329
330
  catch (err) {
330
331
  const message = err instanceof Error ? err.message : String(err);
331
332
  process.stderr.write(`Error: ${message}\n`);
332
- process.exit(1);
333
+ await exitWithAnalytics(1);
333
334
  return;
334
335
  }
335
336
  }
@@ -338,7 +339,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
338
339
  const model = opts.model ?? savedCreds.agentModel;
339
340
  if (!agentName || !isAgentName(agentName)) {
340
341
  process.stderr.write(`Error: no agent configured. Pass --agent <name> or run experiences setup. Accepted values: ${AGENT_NAMES.join(', ')}\n`);
341
- process.exit(1);
342
+ await exitWithAnalytics(1);
342
343
  return;
343
344
  }
344
345
  const agent = agentName;
@@ -352,7 +353,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
352
353
  .catch(() => false);
353
354
  if (!exists) {
354
355
  process.stderr.write(`Error: custom prompt path not found: ${resolvedPath}\n`);
355
- process.exit(1);
356
+ await exitWithAnalytics(1);
356
357
  return;
357
358
  }
358
359
  if (!selectPromptPath.toLowerCase().endsWith('.md')) {
@@ -360,7 +361,8 @@ export function registerAnalyzeSelectAgentCommand(program) {
360
361
  }
361
362
  process.stderr.write(formatCustomPromptBanner('select', resolvedPath));
362
363
  }
363
- const sessionId = resolveSessionId(opts.session);
364
+ const sessionId = await resolveSessionId(opts.session);
365
+ await bindAnalyticsSessionId(sessionId);
364
366
  const selectionRoot = resolveProjectRoot(sessionId, opts.projectRoot);
365
367
  const db = openPipelineDb();
366
368
  let rawComponents;
@@ -374,7 +376,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
374
376
  }
375
377
  if (rawComponents.length === 0) {
376
378
  process.stderr.write(`Error: session '${sessionId}' has no raw components. Run analyze extract first.\n`);
377
- process.exit(1);
379
+ await exitWithAnalytics(1);
378
380
  return;
379
381
  }
380
382
  // Re-run validation (not persisted to DB, so always recompute).
@@ -399,7 +401,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
399
401
  lines.push('');
400
402
  lines.push('Re-run with --exclude-invalid to auto-reject these components, or fix them in source first.');
401
403
  process.stderr.write(lines.join('\n') + '\n');
402
- process.exit(1);
404
+ await exitWithAnalytics(1);
403
405
  return;
404
406
  }
405
407
  const componentsForAgent = validatedComponents.filter((comp) => !shouldExcludeDueToValidation(comp));
@@ -426,7 +428,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
426
428
  if (opts.dryRun) {
427
429
  if (selectionCandidates.length === 0) {
428
430
  process.stderr.write('No valid components to preview — all components were excluded due to validation errors.\n');
429
- process.exit(0);
431
+ await exitWithAnalytics(0);
430
432
  return;
431
433
  }
432
434
  const first = selectionCandidates[0];
@@ -438,7 +440,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
438
440
  skillPathOverride: selectPromptPath ? resolve(selectPromptPath) : undefined,
439
441
  });
440
442
  process.stdout.write(prompt + '\n');
441
- process.exit(0);
443
+ await exitWithAnalytics(0);
442
444
  return;
443
445
  }
444
446
  // --no-cache is the global kill-switch; --no-select-cache is the stage-
@@ -504,7 +506,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
504
506
  }
505
507
  catch (error) {
506
508
  process.stderr.write(`Error: unable to initialize select session.\n${error instanceof Error ? error.message : String(error)}\n`);
507
- process.exit(1);
509
+ await exitWithAnalytics(1);
508
510
  return;
509
511
  }
510
512
  const paths = await getRefineSessionPaths(sessionId, artifactsRoot);
@@ -550,6 +552,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
550
552
  finally {
551
553
  stepDb.close();
552
554
  }
555
+ enrichCommandResult({ accepted_component_count: accepted.length });
553
556
  process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length} Needs review: ${unresolved.length}\n`);
554
557
  });
555
558
  }
@@ -45,6 +45,9 @@ export declare class ImportApiClient {
45
45
  private spaceId;
46
46
  private environmentId;
47
47
  private retry;
48
+ private lastRequestId?;
49
+ getLastRequestId(): string | undefined;
50
+ private noteRequestId;
48
51
  constructor(opts: ApiClientOptions);
49
52
  private base;
50
53
  private headers;
@@ -180,6 +180,15 @@ export class ImportApiClient {
180
180
  spaceId;
181
181
  environmentId;
182
182
  retry;
183
+ lastRequestId;
184
+ getLastRequestId() {
185
+ return this.lastRequestId;
186
+ }
187
+ noteRequestId(response) {
188
+ const requestId = response.headers.get('x-contentful-request-id');
189
+ if (requestId)
190
+ this.lastRequestId = requestId;
191
+ }
183
192
  constructor(opts) {
184
193
  this.host = toApiHost(opts.host);
185
194
  this.token = opts.cmaToken;
@@ -237,8 +246,10 @@ export class ImportApiClient {
237
246
  await this.retry.sleep(delayMs);
238
247
  continue;
239
248
  }
240
- if (!isTransientStatus(result.response.status))
249
+ if (!isTransientStatus(result.response.status)) {
250
+ this.noteRequestId(result.response);
241
251
  return result;
252
+ }
242
253
  if (attempt === this.retry.maxAttempts) {
243
254
  const body = stringifyError(result.error);
244
255
  const guidance = `The ${phase} request failed after ${attempt} attempts because the service remained unavailable. ` +
@@ -273,6 +284,7 @@ export class ImportApiClient {
273
284
  // false positives that don't apply to the design-systems API authorization path.
274
285
  const url = `${this.host}/users/me`;
275
286
  const res = await request(url, { token: this.token });
287
+ this.noteRequestId(res);
276
288
  if (res.status === 401) {
277
289
  throw new ApiError('CMA token is invalid or revoked', res.status, await res.text());
278
290
  }
@@ -298,6 +310,7 @@ export class ImportApiClient {
298
310
  catch {
299
311
  return; // network error — don't block; the real call will surface it
300
312
  }
313
+ this.noteRequestId(res);
301
314
  if (res.ok)
302
315
  return;
303
316
  if (res.status >= 500)
@@ -11,7 +11,7 @@ export declare function formatSlotCycleReport(cycles: ReturnType<typeof findSlot
11
11
  export declare function assertNoSlotCycles(components: Array<{
12
12
  key: string;
13
13
  entry: CDFComponentEntry;
14
- }>): void;
14
+ }>): Promise<void>;
15
15
  export declare function extractComponentsFromManifest(manifest: {
16
16
  componentsManifest?: Record<string, unknown>;
17
17
  } | null | undefined): Array<{
@@ -16,9 +16,10 @@ import { addAllowDeletionsOption, addArtifactInputOptions, addCompositionOptions
16
16
  import { stripAllowedComponents } from '../import/strip-allowed-components.js';
17
17
  import { readExperiencesCredentials } from '../credentials-store.js';
18
18
  import { getInteractiveTerminalSupport, requireInteractiveTerminal } from '../lib/terminal-capabilities.js';
19
- function die(message) {
19
+ import { bindAnalyticsSessionId, exitWithAnalytics, failureFromApiError, recordApplyOutcome, recordContentfulContext, } from '../analytics/index.js';
20
+ async function die(message, fields = {}) {
20
21
  process.stderr.write(`${message}\n`);
21
- process.exit(1);
22
+ return exitWithAnalytics(1, fields);
22
23
  }
23
24
  async function pathExists(p) {
24
25
  return access(p)
@@ -27,7 +28,7 @@ async function pathExists(p) {
27
28
  }
28
29
  async function assertFileExists(flag, p) {
29
30
  if (!(await pathExists(p)))
30
- die(`Error: file not found: ${p} (from ${flag})`);
31
+ return await die(`Error: file not found: ${p} (from ${flag})`);
31
32
  }
32
33
  async function readJsonFile(flag, p) {
33
34
  let text;
@@ -35,13 +36,13 @@ async function readJsonFile(flag, p) {
35
36
  text = await readFile(p, 'utf8');
36
37
  }
37
38
  catch {
38
- die(`Error: file not found: ${p} (from ${flag})`);
39
+ return await die(`Error: file not found: ${p} (from ${flag})`);
39
40
  }
40
41
  try {
41
42
  return JSON.parse(text);
42
43
  }
43
44
  catch {
44
- die(`Error: ${flag} is not valid JSON: ${p}`);
45
+ return await die(`Error: ${flag} is not valid JSON: ${p}`);
45
46
  }
46
47
  }
47
48
  const IGNORE_TOKEN_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.nuxt', '.git']);
@@ -83,12 +84,12 @@ export async function readTokensFromPath(flag, p) {
83
84
  s = await stat(p);
84
85
  }
85
86
  catch {
86
- die(`Error: file not found: ${p} (from ${flag})`);
87
+ return await die(`Error: file not found: ${p} (from ${flag})`);
87
88
  }
88
89
  if (s.isDirectory()) {
89
90
  const files = await collectJsonFiles(p);
90
91
  if (files.length === 0)
91
- die(`Error: no .json files found in directory: ${p} (from ${flag})`);
92
+ return await die(`Error: no .json files found in directory: ${p} (from ${flag})`);
92
93
  const merged = {};
93
94
  for (const file of files.sort()) {
94
95
  let text;
@@ -111,36 +112,36 @@ export async function readTokensFromPath(flag, p) {
111
112
  }
112
113
  const { valid, errors } = validateDTCG(merged);
113
114
  if (!valid)
114
- die(`Error: ${flag} contains invalid token types:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}`);
115
+ return await die(`Error: ${flag} contains invalid token types:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}`);
115
116
  return flattenDTCG(merged, '');
116
117
  }
117
118
  const raw = await readJsonFile(flag, p);
118
119
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
119
- die(`Error: ${flag} is not valid JSON: expected an object`);
120
+ return await die(`Error: ${flag} is not valid JSON: expected an object`);
120
121
  }
121
122
  const { valid, errors } = validateDTCG(raw);
122
123
  if (!valid)
123
- die(`Error: ${flag} contains invalid token types:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}`);
124
+ return await die(`Error: ${flag} contains invalid token types:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}`);
124
125
  return flattenDTCG(raw, '');
125
126
  }
126
127
  async function resolveSharedInputs(opts) {
127
128
  if (!opts.components && !opts.tokens && !opts.session) {
128
- die('Error: at least one of --components, --tokens, or --session is required');
129
+ return await die('Error: at least one of --components, --tokens, or --session is required');
129
130
  }
130
131
  if (opts.session && opts.components) {
131
- die('Error: --session and --components are mutually exclusive');
132
+ return await die('Error: --session and --components are mutually exclusive');
132
133
  }
133
134
  const spaceId = opts.spaceId ?? process.env.CONTENTFUL_SPACE_ID;
134
135
  const environmentId = opts.environmentId ?? process.env.CONTENTFUL_ENVIRONMENT_ID;
135
136
  if (!spaceId)
136
- die('Error: --space-id is required (or set CONTENTFUL_SPACE_ID)');
137
+ return await die('Error: --space-id is required (or set CONTENTFUL_SPACE_ID)');
137
138
  if (!environmentId)
138
- die('Error: --environment-id is required (or set CONTENTFUL_ENVIRONMENT_ID)');
139
+ return await die('Error: --environment-id is required (or set CONTENTFUL_ENVIRONMENT_ID)');
139
140
  opts.spaceId = spaceId;
140
141
  opts.environmentId = environmentId;
141
142
  const cmaToken = opts.cmaToken ?? process.env.CONTENTFUL_MANAGEMENT_TOKEN;
142
143
  if (!cmaToken) {
143
- die('Error: CMA token is required. Pass --cma-token or set CONTENTFUL_MANAGEMENT_TOKEN');
144
+ return await die('Error: CMA token is required. Pass --cma-token or set CONTENTFUL_MANAGEMENT_TOKEN');
144
145
  }
145
146
  if (opts.components)
146
147
  await assertFileExists('--components', opts.components);
@@ -154,14 +155,14 @@ async function resolveSharedInputs(opts) {
154
155
  db.close();
155
156
  }
156
157
  if (components.length === 0) {
157
- die(`Error: session '${opts.session}' has no generated components. Run generate components first.`);
158
+ return await die(`Error: session '${opts.session}' has no generated components. Run generate components first.`);
158
159
  }
159
160
  }
160
161
  else if (opts.components) {
161
162
  const raw = await readJsonFile('--components', opts.components);
162
163
  const result = validateCDF(raw);
163
164
  if (!result.valid) {
164
- die(`Error: --components failed schema validation: ${result.errors.map((e) => e.message).join(', ')}`);
165
+ return await die(`Error: --components failed schema validation: ${result.errors.map((e) => e.message).join(', ')}`);
165
166
  }
166
167
  components = result.components;
167
168
  }
@@ -213,12 +214,12 @@ export function formatSlotCycleReport(cycles) {
213
214
  }
214
215
  return lines;
215
216
  }
216
- export function assertNoSlotCycles(components) {
217
+ export async function assertNoSlotCycles(components) {
217
218
  const cycles = detectSlotCycles(components);
218
219
  if (cycles.length === 0)
219
220
  return;
220
221
  process.stderr.write(formatSlotCycleReport(cycles).join('\n') + '\n');
221
- process.exit(1);
222
+ await exitWithAnalytics(1);
222
223
  }
223
224
  export function extractComponentsFromManifest(manifest) {
224
225
  const componentsManifest = manifest?.componentsManifest;
@@ -427,18 +428,24 @@ export function registerApplyCommand(program) {
427
428
  }
428
429
  catch (e) {
429
430
  if (e instanceof ApiError)
430
- die(`Error: ${formatApiError(e)}`);
431
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
431
432
  throw e;
432
433
  }
433
434
  const { components, tokens, client } = inputs;
435
+ const spaceId = opts.spaceId;
436
+ const environmentId = opts.environmentId;
437
+ await bindAnalyticsSessionId(opts.session, {
438
+ space_key: spaceId,
439
+ environment_key: environmentId,
440
+ });
434
441
  try {
435
442
  await client.validateToken();
436
443
  }
437
444
  catch (e) {
438
445
  if (e instanceof ApiError)
439
- die(`Error: ${formatApiError(e)}`);
446
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
440
447
  const cause = e instanceof Error && e.cause instanceof Error ? e.cause.message : '';
441
- die(`Error: unable to connect to API host${cause ? `: ${cause}` : ''}`);
448
+ return await die(`Error: unable to connect to API host${cause ? `: ${cause}` : ''}`);
442
449
  }
443
450
  const manifest = buildManifest(components, tokens);
444
451
  let preview;
@@ -447,11 +454,10 @@ export function registerApplyCommand(program) {
447
454
  }
448
455
  catch (e) {
449
456
  if (e instanceof ApiError)
450
- die(`Error: ${formatApiError(e)}`);
457
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
451
458
  throw e;
452
459
  }
453
- const spaceId = opts.spaceId;
454
- const environmentId = opts.environmentId;
460
+ recordContentfulContext(client, spaceId, environmentId);
455
461
  if (getInteractiveTerminalSupport().supported) {
456
462
  const { waitUntilExit } = render(createElement(ServerPreviewApp, {
457
463
  preview,
@@ -463,7 +469,7 @@ export function registerApplyCommand(program) {
463
469
  }
464
470
  else {
465
471
  process.stdout.write(JSON.stringify(buildPreviewOutput(preview, spaceId, environmentId), null, 2) + '\n');
466
- process.exit(0);
472
+ await exitWithAnalytics(0);
467
473
  }
468
474
  });
469
475
  const pushCmd = applyCmd.command('push').description('Write component types and design tokens to Contentful ExO');
@@ -480,7 +486,7 @@ export function registerApplyCommand(program) {
480
486
  const isTTY = getInteractiveTerminalSupport().supported;
481
487
  if (!isTTY && !opts.yes) {
482
488
  process.stderr.write('Error: apply push requires --yes in non-interactive mode\n');
483
- process.exit(1);
489
+ await exitWithAnalytics(1);
484
490
  }
485
491
  let inputs;
486
492
  try {
@@ -488,17 +494,23 @@ export function registerApplyCommand(program) {
488
494
  }
489
495
  catch (e) {
490
496
  if (e instanceof ApiError)
491
- die(`Error: ${formatApiError(e, opts.verbose)}`);
497
+ return await die(`Error: ${formatApiError(e, opts.verbose)}`, failureFromApiError(e));
492
498
  throw e;
493
499
  }
494
500
  const { components, tokens, client } = inputs;
495
- assertNoSlotCycles(components);
501
+ const spaceId = opts.spaceId;
502
+ const environmentId = opts.environmentId;
503
+ await bindAnalyticsSessionId(opts.session, {
504
+ space_key: spaceId,
505
+ environment_key: environmentId,
506
+ });
507
+ await assertNoSlotCycles(components);
496
508
  try {
497
509
  await client.validateToken();
498
510
  }
499
511
  catch (e) {
500
512
  if (e instanceof ApiError)
501
- die(`Error: ${formatApiError(e, opts.verbose)}`);
513
+ return await die(`Error: ${formatApiError(e, opts.verbose)}`, failureFromApiError(e));
502
514
  throw e;
503
515
  }
504
516
  const manifest = buildManifest(components, tokens);
@@ -508,11 +520,10 @@ export function registerApplyCommand(program) {
508
520
  }
509
521
  catch (e) {
510
522
  if (e instanceof ApiError)
511
- die(`Error: ${formatApiError(e, opts.verbose)}`);
523
+ return await die(`Error: ${formatApiError(e, opts.verbose)}`, failureFromApiError(e));
512
524
  throw e;
513
525
  }
514
- const spaceId = opts.spaceId;
515
- const environmentId = opts.environmentId;
526
+ recordContentfulContext(client, spaceId, environmentId);
516
527
  if (opts.dryRun) {
517
528
  if (isTTY) {
518
529
  const { waitUntilExit } = render(createElement(ServerPreviewApp, {
@@ -526,7 +537,7 @@ export function registerApplyCommand(program) {
526
537
  else {
527
538
  process.stdout.write(JSON.stringify(buildPreviewOutput(preview, spaceId, environmentId), null, 2) + '\n');
528
539
  }
529
- process.exit(0);
540
+ await exitWithAnalytics(0);
530
541
  }
531
542
  if (isEmptyPreview(preview)) {
532
543
  if (isTTY && !opts.yes) {
@@ -535,14 +546,14 @@ export function registerApplyCommand(program) {
535
546
  else {
536
547
  process.stdout.write(JSON.stringify(buildPreviewOutput(preview, spaceId, environmentId), null, 2) + '\n');
537
548
  }
538
- process.exit(0);
549
+ await exitWithAnalytics(0);
539
550
  }
540
551
  const breakingWithImpact = hasBreakingChangesWithImpact(preview);
541
552
  if (!isTTY || opts.yes) {
542
553
  if (breakingWithImpact && !opts.force) {
543
554
  process.stderr.write('Error: breaking changes with downstream impact detected. Use --force to acknowledge.\n');
544
555
  process.stdout.write(JSON.stringify(buildPreviewOutput(preview, spaceId, environmentId), null, 2) + '\n');
545
- process.exit(1);
556
+ await exitWithAnalytics(1);
546
557
  }
547
558
  const verbose = opts.verbose ?? false;
548
559
  if (verbose) {
@@ -557,7 +568,7 @@ export function registerApplyCommand(program) {
557
568
  }
558
569
  catch (e) {
559
570
  if (e instanceof ApiError)
560
- die(`Error: ${formatApiError(e, opts.verbose)}`);
571
+ return await die(`Error: ${formatApiError(e, opts.verbose)}`, failureFromApiError(e));
561
572
  throw e;
562
573
  }
563
574
  process.stderr.write(`Apply operation started: ${operation.sys.id}\n`);
@@ -566,12 +577,14 @@ export function registerApplyCommand(program) {
566
577
  }
567
578
  catch (e) {
568
579
  if (e instanceof ApiError)
569
- die(`Error: ${formatApiError(e, opts.verbose)}`);
580
+ return await die(`Error: ${formatApiError(e, opts.verbose)}`, failureFromApiError(e));
570
581
  throw e;
571
582
  }
572
583
  const summary = buildApplyOutput(operation, spaceId, environmentId, opts.host);
584
+ recordApplyOutcome(client, spaceId, environmentId, operation);
573
585
  process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
574
- process.exit(operation.sys.status === 'succeeded' ? 0 : 1);
586
+ const exitCode = operation.sys.status === 'succeeded' ? 0 : 1;
587
+ await exitWithAnalytics(exitCode);
575
588
  return;
576
589
  }
577
590
  await new Promise((resolvePromise) => {
@@ -621,6 +634,7 @@ export function registerApplyCommand(program) {
621
634
  }
622
635
  throw e;
623
636
  }
637
+ recordApplyOutcome(client, spaceId, environmentId, operation);
624
638
  instance.rerender(createElement(ServerApplyDone, {
625
639
  operation,
626
640
  spaceId,
@@ -639,7 +653,7 @@ export function registerApplyCommand(program) {
639
653
  void runApply(acknowledge, applyDeletions);
640
654
  },
641
655
  onCancel: () => {
642
- process.exit(0);
656
+ void exitWithAnalytics(0);
643
657
  },
644
658
  }));
645
659
  void instance.waitUntilExit().then(() => resolvePromise());
@@ -664,17 +678,17 @@ export function registerApplyCommand(program) {
664
678
  }
665
679
  catch (e) {
666
680
  if (e instanceof ApiError)
667
- die(`Error: ${formatApiError(e)}`);
681
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
668
682
  throw e;
669
683
  }
670
684
  const { components, tokens, client } = inputs;
671
- assertNoSlotCycles(components);
685
+ await assertNoSlotCycles(components);
672
686
  try {
673
687
  await client.validateToken();
674
688
  }
675
689
  catch (e) {
676
690
  if (e instanceof ApiError)
677
- die(`Error: ${formatApiError(e)}`);
691
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
678
692
  throw e;
679
693
  }
680
694
  const fullManifest = buildManifest(components, tokens);
@@ -684,21 +698,26 @@ export function registerApplyCommand(program) {
684
698
  }
685
699
  catch (e) {
686
700
  if (e instanceof ApiError)
687
- die(`Error: ${formatApiError(e)}`);
701
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
688
702
  throw e;
689
703
  }
690
704
  const spaceId = opts.spaceId;
691
705
  const environmentId = opts.environmentId;
706
+ await bindAnalyticsSessionId(opts.session, {
707
+ space_key: spaceId,
708
+ environment_key: environmentId,
709
+ });
710
+ recordContentfulContext(client, spaceId, environmentId);
692
711
  const entities = getSelectableEntities(preview);
693
712
  if (entities.length === 0) {
694
713
  process.stderr.write('Nothing to change — design system is up to date.\n');
695
- process.exit(0);
714
+ await exitWithAnalytics(0);
696
715
  }
697
716
  if (nonInteractive) {
698
717
  const selectedKeys = resolveNonInteractiveSelection(entities, opts);
699
718
  if (selectedKeys.size === 0) {
700
719
  process.stderr.write('No entities matched selection criteria.\n');
701
- process.exit(0);
720
+ await exitWithAnalytics(0);
702
721
  }
703
722
  const selectedComponentKeys = new Set();
704
723
  const selectedTokenPaths = new Set();
@@ -714,7 +733,7 @@ export function registerApplyCommand(program) {
714
733
  const hasBreaking = entities.some((e) => e.isBreaking && selectedKeys.has(makeSelectKey(e.kind, e.id)));
715
734
  if (hasBreaking && !opts.force) {
716
735
  process.stderr.write('Error: selection includes breaking changes. Use --force to acknowledge.\n');
717
- process.exit(1);
736
+ await exitWithAnalytics(1);
718
737
  }
719
738
  let operation;
720
739
  try {
@@ -725,7 +744,7 @@ export function registerApplyCommand(program) {
725
744
  }
726
745
  catch (e) {
727
746
  if (e instanceof ApiError)
728
- die(`Error: ${formatApiError(e)}`);
747
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
729
748
  throw e;
730
749
  }
731
750
  process.stderr.write(`Apply operation started: ${operation.sys.id}\n`);
@@ -734,12 +753,13 @@ export function registerApplyCommand(program) {
734
753
  }
735
754
  catch (e) {
736
755
  if (e instanceof ApiError)
737
- die(`Error: ${formatApiError(e)}`);
756
+ return await die(`Error: ${formatApiError(e)}`, failureFromApiError(e));
738
757
  throw e;
739
758
  }
740
759
  const summary = buildApplyOutput(operation, spaceId, environmentId, opts.host);
760
+ recordApplyOutcome(client, spaceId, environmentId, operation);
741
761
  process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
742
- process.exit(operation.sys.status === 'succeeded' ? 0 : 1);
762
+ await exitWithAnalytics(operation.sys.status === 'succeeded' ? 0 : 1);
743
763
  return;
744
764
  }
745
765
  await new Promise((resolvePromise) => {
@@ -801,6 +821,7 @@ export function registerApplyCommand(program) {
801
821
  }
802
822
  throw e;
803
823
  }
824
+ recordApplyOutcome(client, spaceId, environmentId, operation);
804
825
  instance.rerender(createElement(ServerApplyDone, {
805
826
  operation,
806
827
  spaceId,
@@ -14,6 +14,7 @@ import { hashPromptForSkill } from '../session/cache-keys.js';
14
14
  import { getRefineArtifactsRoot, getRefineSessionPaths } from '../analyze/select/persistence.js';
15
15
  import { readExperiencesCredentials } from '../credentials-store.js';
16
16
  import { addAgentModelOptions } from '../lib/agent-model-options.js';
17
+ import { bindAnalyticsSessionId, exitWithAnalytics } from '../analytics/index.js';
17
18
  const execFileAsync = promisify(execFile);
18
19
  const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
19
20
  const DEFAULT_COMPONENT_CONCURRENCY = 10;
@@ -23,7 +24,8 @@ const invoker = createLocalCliAgentInvoker({
23
24
  });
24
25
  function die(message) {
25
26
  process.stderr.write(`${message}\n`);
26
- process.exit(1);
27
+ void exitWithAnalytics(1);
28
+ throw new Error('exit');
27
29
  }
28
30
  async function pathExists(p) {
29
31
  return access(p)
@@ -276,7 +278,8 @@ function resolveSessionId(sessionFlag) {
276
278
  .get();
277
279
  if (!row) {
278
280
  process.stderr.write('Error: no completed analyze extract session found. Run analyze extract first, or pass --session <id>.\n');
279
- process.exit(1);
281
+ void exitWithAnalytics(1);
282
+ throw new Error('exit');
280
283
  }
281
284
  return row.id;
282
285
  }
@@ -340,6 +343,7 @@ async function runGenerateSkill(skill, opts, verbose = false) {
340
343
  let allComponents;
341
344
  if (skill === 'components') {
342
345
  sessionId = resolveSessionId(opts.session);
346
+ await bindAnalyticsSessionId(sessionId);
343
347
  const acceptedNames = await loadAcceptedNames(sessionId);
344
348
  const db = openPipelineDb();
345
349
  try {
@@ -396,7 +400,7 @@ async function runGenerateSkill(skill, opts, verbose = false) {
396
400
  skillPathOverride: generatePromptPath,
397
401
  });
398
402
  process.stdout.write(prompt + '\n');
399
- process.exit(0);
403
+ await exitWithAnalytics(0);
400
404
  }
401
405
  const binary = resolveBinary(agent);
402
406
  if (!(await assertBinaryInPath(binary))) {
@@ -405,7 +409,7 @@ async function runGenerateSkill(skill, opts, verbose = false) {
405
409
  skill,
406
410
  sessionId: sessionId ?? '',
407
411
  });
408
- process.exit(1);
412
+ await exitWithAnalytics(1);
409
413
  }
410
414
  if (skill === 'components' && allComponents && sessionId) {
411
415
  const db = openPipelineDb();
@@ -471,6 +475,8 @@ async function runGenerateSkill(skill, opts, verbose = false) {
471
475
  resolvedSessionId = newId;
472
476
  }
473
477
  }
478
+ sessionId = resolvedSessionId;
479
+ await bindAnalyticsSessionId(resolvedSessionId);
474
480
  const tokenPromptHash = await hashPromptForSkill('tokens');
475
481
  // Check cache before invoking agent
476
482
  if (!noCache) {
@@ -483,12 +489,12 @@ async function runGenerateSkill(skill, opts, verbose = false) {
483
489
  // Skip agent invocation — jump to view
484
490
  const viewResult = { skill, agent, sessionId: sessionId ?? '' };
485
491
  if (process.stdout.isTTY) {
486
- const { waitUntilExit } = render(createElement(GenerateView, { result: viewResult, onExit: () => process.exit(0) }));
492
+ const { waitUntilExit } = render(createElement(GenerateView, { result: viewResult, onExit: () => void exitWithAnalytics(0) }));
487
493
  await waitUntilExit();
488
494
  }
489
495
  else {
490
496
  process.stdout.write(`generate complete\nskill: ${skill}\nagent: ${agent}\nsession=${sessionId ?? ''}\n`);
491
- process.exit(0);
497
+ await exitWithAnalytics(0);
492
498
  }
493
499
  return;
494
500
  }
@@ -523,7 +529,7 @@ async function runGenerateSkill(skill, opts, verbose = false) {
523
529
  process.stderr.write(`Error: agent produced no set_token calls.\n` +
524
530
  `Run with --dry-run to inspect the prompt.\n\n` +
525
531
  `Agent output:\n${result.stdout}\n`);
526
- process.exit(1);
532
+ await exitWithAnalytics(1);
527
533
  }
528
534
  if (tokenWarnings.length > 0) {
529
535
  process.stderr.write(`Warnings:\n${tokenWarnings.map((w) => ` ${w}`).join('\n')}\n`);
@@ -548,13 +554,13 @@ async function runGenerateSkill(skill, opts, verbose = false) {
548
554
  if (process.stdout.isTTY) {
549
555
  const { waitUntilExit } = render(createElement(GenerateView, {
550
556
  result: viewResult,
551
- onExit: () => process.exit(0),
557
+ onExit: () => void exitWithAnalytics(0),
552
558
  }));
553
559
  await waitUntilExit();
554
560
  }
555
561
  else {
556
562
  process.stdout.write(`generate complete\nskill: ${skill}\nagent: ${agent}\nsession=${sessionId ?? ''}\n`);
557
- process.exit(0);
563
+ await exitWithAnalytics(0);
558
564
  }
559
565
  }
560
566
  function addAgentFlags(cmd) {
@@ -99,6 +99,7 @@ async function runNonInteractive(opts, skill) {
99
99
  process.stderr.write(`Accepted: ${accepted.length} Rejected: ${rejected.length}\n`);
100
100
  }
101
101
  export function registerGenerateEditCommand(parent, skill) {
102
+ // TODO(analytics): bindAnalyticsSessionId when generate edit ships — tracked in schema as generate_edit.
102
103
  parent
103
104
  .command('edit')
104
105
  .description(`Review and correct generate ${skill} output before pushing`)