@contentful/experience-design-system-cli 2.16.1-dev-build-2b79e46.0 → 2.16.1-dev-build-d068963.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.
package/README.md CHANGED
@@ -444,7 +444,9 @@ Wizard run history is separate: `~/.config/experiences/runs.json`.
444
444
  - 80+ columns recommended for full sidebar + detail view
445
445
  - 120+ columns required to show the source code panel in `analyze select`
446
446
  - `NO_COLOR=1` suppresses all ANSI color output
447
- - Windows: supported via Ink v4; known limitations with older ConEmu and cmd.exe
447
+ - Interactive views require both stdin and stdout to be TTYs and stdin to support raw mode. Read-only views fall back to plain or JSON output when those capabilities are unavailable; commands that require input stop with the relevant non-interactive flags in the error message.
448
+ - On Windows, use Windows Terminal with PowerShell. Older ConEmu and cmd.exe hosts may not provide the raw-mode support the interactive UI needs.
449
+ - To avoid the interactive UI, use the command's non-interactive options: `import --yes` (with credentials) or `import --no-push --auto-accept-scope`, `analyze select --select-all`, `apply select --select-all`, and `apply push --yes`.
448
450
 
449
451
  ---
450
452
 
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.16.1-dev-build-2b79e46.0",
3
+ "version": "2.16.1-dev-build-d068963.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,7 @@ import { parsePromptOverrides, resolvePromptOverride } from '../lib/prompt-overr
22
22
  import { runAgent } from '../generate/agent-runner.js';
23
23
  import { readExperiencesCredentials } from '../credentials-store.js';
24
24
  import { buildAnalyzeViewRows, partitionGlobalWarnings } from './build-analyze-view-rows.js';
25
+ import { getInteractiveTerminalSupport } from '../lib/terminal-capabilities.js';
25
26
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.svelte', '.ts', '.tsx', '.vue']);
26
27
  const IGNORED_DIRECTORY_NAMES = new Set([
27
28
  '.git',
@@ -505,7 +506,7 @@ export function registerAnalyzeCommand(program) {
505
506
  totalErrors,
506
507
  globalWarnings,
507
508
  };
508
- if (process.stdout.isTTY) {
509
+ if (getInteractiveTerminalSupport().supported) {
509
510
  const { waitUntilExit } = render(createElement(AnalyzeView, {
510
511
  result: analyzeResult,
511
512
  onExit: () => process.exit(0),
@@ -2,6 +2,7 @@ import { access, readFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { createElement } from 'react';
4
4
  import { render } from 'ink';
5
+ import { requireInteractiveTerminal } from '../../lib/terminal-capabilities.js';
5
6
  import { getRefineArtifactsRoot, ensureRefineSession, getRefineSessionPaths, saveReviewState } from './persistence.js';
6
7
  import { loadReviewInput } from './parser.js';
7
8
  import { App } from './tui/App.js';
@@ -459,10 +460,9 @@ export function registerAnalyzeEditCommand(program) {
459
460
  `current-review-state.json=${paths.statePath}\n`);
460
461
  return;
461
462
  }
462
- if (!process.stdout.isTTY) {
463
- process.stderr.write('Error: analyze select requires an interactive terminal\n');
464
- process.exit(1);
465
- }
463
+ requireInteractiveTerminal({
464
+ alternative: 'pass `--accept-all`, `--select-all`, `--reject`, `--deselect`, `--select`, or `--patch`',
465
+ });
466
466
  if (process.stdout.columns !== undefined && process.stdout.columns < 60) {
467
467
  process.stderr.write(`Error: terminal too narrow (${process.stdout.columns} cols). Resize to 60+ columns.\n`);
468
468
  process.exit(1);
@@ -8,11 +8,18 @@ export interface ApiClientOptions {
8
8
  cmaToken: string;
9
9
  spaceId: string;
10
10
  environmentId: string;
11
+ retry?: {
12
+ maxAttempts?: number;
13
+ initialDelayMs?: number;
14
+ maxDelayMs?: number;
15
+ sleep?: (delayMs: number) => Promise<void>;
16
+ };
11
17
  }
12
18
  export declare class ApiError extends Error {
13
19
  readonly status: number;
14
20
  readonly body: string;
15
- constructor(message: string, status: number, body: string);
21
+ readonly guidance?: string | undefined;
22
+ constructor(message: string, status: number, body: string, guidance?: string | undefined);
16
23
  }
17
24
  export interface PreviewValidationError {
18
25
  componentName: string;
@@ -37,9 +44,11 @@ export declare class ImportApiClient {
37
44
  private token;
38
45
  private spaceId;
39
46
  private environmentId;
47
+ private retry;
40
48
  constructor(opts: ApiClientOptions);
41
49
  private base;
42
50
  private headers;
51
+ private fetchWithRetry;
43
52
  validateToken(): Promise<void>;
44
53
  previewImport(manifest: ManifestPayload): Promise<ServerPreviewResponse>;
45
54
  applyImport(manifest: ManifestPayload, acknowledgeBreakingChanges: boolean): Promise<ApplyOperationResponse>;
@@ -12,6 +12,26 @@ export const APPLY_ERROR_PREFIX = 'apply failed:';
12
12
  // to the prefixes as a deliberate, named contract rather than an inline
13
13
  // magic string in the orchestrator.
14
14
  export const VALIDATION_FAILED_CODE = '"ValidationFailed"';
15
+ const MAX_RETRY_AFTER_MS = 60_000;
16
+ function defaultSleep(delayMs) {
17
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
18
+ }
19
+ function isTransientStatus(status) {
20
+ return status >= 500 && status <= 599;
21
+ }
22
+ function retryAfterMs(response) {
23
+ const value = response.headers?.get('retry-after')?.trim();
24
+ if (!value)
25
+ return undefined;
26
+ const seconds = Number(value);
27
+ const delayMs = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
28
+ if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > MAX_RETRY_AFTER_MS)
29
+ return undefined;
30
+ return Math.round(delayMs);
31
+ }
32
+ function errorMessage(error) {
33
+ return error instanceof Error && error.message ? error.message : String(error);
34
+ }
15
35
  // Cap on the body slice appended to ApiError.message. Bumped from 1000 →
16
36
  // 16384 so realistic 422 ValidationFailed reports (which list every
17
37
  // offending component, ~100 chars per error, easily exceeds 1KB once you
@@ -24,10 +44,12 @@ const ERROR_BODY_LOG_CAP = 16384;
24
44
  export class ApiError extends Error {
25
45
  status;
26
46
  body;
27
- constructor(message, status, body) {
47
+ guidance;
48
+ constructor(message, status, body, guidance) {
28
49
  super(message);
29
50
  this.status = status;
30
51
  this.body = body;
52
+ this.guidance = guidance;
31
53
  if (body) {
32
54
  const trimmed = body.length > ERROR_BODY_LOG_CAP ? body.slice(0, ERROR_BODY_LOG_CAP) + '…' : body;
33
55
  this.message = `${message}\n${trimmed}`;
@@ -136,11 +158,19 @@ export class ImportApiClient {
136
158
  token;
137
159
  spaceId;
138
160
  environmentId;
161
+ retry;
139
162
  constructor(opts) {
140
163
  this.host = toApiHost(opts.host);
141
164
  this.token = opts.cmaToken;
142
165
  this.spaceId = opts.spaceId;
143
166
  this.environmentId = opts.environmentId;
167
+ const initialDelayMs = Math.max(0, opts.retry?.initialDelayMs ?? 250);
168
+ this.retry = {
169
+ maxAttempts: Math.max(1, Math.floor(opts.retry?.maxAttempts ?? 3)),
170
+ initialDelayMs,
171
+ maxDelayMs: Math.max(initialDelayMs, opts.retry?.maxDelayMs ?? 2000),
172
+ sleep: opts.retry?.sleep ?? defaultSleep,
173
+ };
144
174
  }
145
175
  base() {
146
176
  return `${this.host}/spaces/${this.spaceId}/environments/${this.environmentId}`;
@@ -152,6 +182,72 @@ export class ImportApiClient {
152
182
  'X-Contentful-User-Agent': buildUserAgent(),
153
183
  };
154
184
  }
185
+ async fetchWithRetry(phase, url, init, context = {}) {
186
+ const debug = getDebugLogger();
187
+ const errorPrefix = phase === 'preview' ? PREVIEW_ERROR_PREFIX : 'poll failed:';
188
+ const startedAt = Date.now();
189
+ for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt++) {
190
+ try {
191
+ const response = await fetch(url, init);
192
+ if (!isTransientStatus(response.status))
193
+ return response;
194
+ const body = await response.text();
195
+ if (attempt === this.retry.maxAttempts) {
196
+ const guidance = `The ${phase} request failed after ${attempt} attempts because the service remained unavailable. ` +
197
+ 'Wait a moment and try again.';
198
+ debug.event('apply', `${phase}.error`, {
199
+ ...context,
200
+ attempt,
201
+ maxAttempts: this.retry.maxAttempts,
202
+ status: response.status,
203
+ reason: 'retry_exhausted',
204
+ durationMs: Date.now() - startedAt,
205
+ bodyHead: body.slice(0, 2000),
206
+ });
207
+ throw new ApiError(`${errorPrefix} ${response.status}`, response.status, body, guidance);
208
+ }
209
+ const backoffMs = Math.min(this.retry.initialDelayMs * 2 ** (attempt - 1), this.retry.maxDelayMs);
210
+ const delayMs = retryAfterMs(response) ?? backoffMs;
211
+ debug.event('apply', `${phase}.retry`, {
212
+ ...context,
213
+ attempt,
214
+ maxAttempts: this.retry.maxAttempts,
215
+ status: response.status,
216
+ reason: 'http_5xx',
217
+ delayMs,
218
+ });
219
+ await this.retry.sleep(delayMs);
220
+ }
221
+ catch (error) {
222
+ if (error instanceof ApiError)
223
+ throw error;
224
+ if (attempt === this.retry.maxAttempts) {
225
+ const body = `The request failed after ${attempt} attempts because of a network error: ${errorMessage(error)}. ` +
226
+ 'Check your connection and try again.';
227
+ debug.event('apply', `${phase}.error`, {
228
+ ...context,
229
+ attempt,
230
+ maxAttempts: this.retry.maxAttempts,
231
+ status: 0,
232
+ reason: 'transport_retry_exhausted',
233
+ durationMs: Date.now() - startedAt,
234
+ });
235
+ throw new ApiError(`${errorPrefix} 0`, 0, body);
236
+ }
237
+ const delayMs = Math.min(this.retry.initialDelayMs * 2 ** (attempt - 1), this.retry.maxDelayMs);
238
+ debug.event('apply', `${phase}.retry`, {
239
+ ...context,
240
+ attempt,
241
+ maxAttempts: this.retry.maxAttempts,
242
+ status: 0,
243
+ reason: 'transport_error',
244
+ delayMs,
245
+ });
246
+ await this.retry.sleep(delayMs);
247
+ }
248
+ }
249
+ throw new Error('Retry attempts exhausted');
250
+ }
155
251
  async validateToken() {
156
252
  // /users/me is the canonical token-validity endpoint — avoids space-membership
157
253
  // false positives that don't apply to the design-systems API authorization path.
@@ -173,7 +269,7 @@ export class ImportApiClient {
173
269
  componentCount: manifest.components?.length ?? 0,
174
270
  tokenCount: manifest.designTokens?.length ?? 0,
175
271
  });
176
- const res = await fetch(url, {
272
+ const res = await this.fetchWithRetry('preview', url, {
177
273
  method: 'POST',
178
274
  headers: this.headers(),
179
275
  body: JSON.stringify(manifest),
@@ -196,19 +292,35 @@ export class ImportApiClient {
196
292
  const debug = getDebugLogger();
197
293
  const startedAt = Date.now();
198
294
  debug.event('apply', 'apply.request', { url, acknowledgeBreakingChanges });
199
- const res = await fetch(url, {
200
- method: 'POST',
201
- headers: this.headers(),
202
- body: JSON.stringify({ ...manifest, acknowledgeBreakingChanges }),
203
- });
295
+ let res;
296
+ try {
297
+ res = await fetch(url, {
298
+ method: 'POST',
299
+ headers: this.headers(),
300
+ body: JSON.stringify({ ...manifest, acknowledgeBreakingChanges }),
301
+ });
302
+ }
303
+ catch (error) {
304
+ const body = `The apply request was not retried because its outcome is unknown and retrying could start a duplicate operation. ` +
305
+ `Check for an existing operation before trying again. Cause: ${errorMessage(error)}`;
306
+ debug.event('apply', 'apply.error', {
307
+ status: 0,
308
+ durationMs: Date.now() - startedAt,
309
+ reason: 'transport_error_not_retried',
310
+ });
311
+ throw new ApiError(`${APPLY_ERROR_PREFIX} 0`, 0, body);
312
+ }
204
313
  if (!res.ok) {
205
314
  const body = await res.text();
315
+ const guidance = isTransientStatus(res.status)
316
+ ? 'The apply request was not retried because the server may already have started an operation and retrying could create a duplicate. Check for an existing operation before trying again.'
317
+ : undefined;
206
318
  debug.event('apply', 'apply.error', {
207
319
  status: res.status,
208
320
  durationMs: Date.now() - startedAt,
209
321
  bodyHead: body.slice(0, 2000),
210
322
  });
211
- throw new ApiError(`${APPLY_ERROR_PREFIX} ${res.status}`, res.status, body);
323
+ throw new ApiError(`${APPLY_ERROR_PREFIX} ${res.status}`, res.status, body, guidance);
212
324
  }
213
325
  const parsed = (await res.json());
214
326
  debug.event('apply', 'apply.accepted', {
@@ -225,10 +337,10 @@ export class ImportApiClient {
225
337
  const terminalStatuses = new Set(['succeeded', 'partial', 'failed']);
226
338
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
227
339
  const url = `${this.base()}/design_systems/imports/apply/${encodeURIComponent(operationId)}`;
228
- const res = await fetch(url, {
340
+ const res = await this.fetchWithRetry('poll', url, {
229
341
  method: 'GET',
230
342
  headers: this.headers(),
231
- });
343
+ }, { operationId });
232
344
  if (!res.ok) {
233
345
  throw new ApiError(`poll failed: ${res.status}`, res.status, await res.text());
234
346
  }
@@ -14,6 +14,7 @@ import { buildPostPushUrl } from '../lib/contentful-urls.js';
14
14
  import { resolveCompositionMode } from '../lib/composition-mode.js';
15
15
  import { stripAllowedComponents } from '../import/strip-allowed-components.js';
16
16
  import { readExperiencesCredentials } from '../credentials-store.js';
17
+ import { getInteractiveTerminalSupport, requireInteractiveTerminal } from '../lib/terminal-capabilities.js';
17
18
  function die(message) {
18
19
  process.stderr.write(`${message}\n`);
19
20
  process.exit(1);
@@ -461,7 +462,7 @@ export function registerApplyCommand(program) {
461
462
  }
462
463
  const spaceId = opts.spaceId;
463
464
  const environmentId = opts.environmentId;
464
- if (process.stdout.isTTY) {
465
+ if (getInteractiveTerminalSupport().supported) {
465
466
  const { waitUntilExit } = render(createElement(ServerPreviewApp, {
466
467
  preview,
467
468
  spaceId,
@@ -491,7 +492,7 @@ export function registerApplyCommand(program) {
491
492
  .option('--force', 'Skip confirmation for breaking changes (for CI)')
492
493
  .option('--dry-run', 'Run preview only without applying')
493
494
  .action(async (opts) => {
494
- const isTTY = process.stdout.isTTY;
495
+ const isTTY = getInteractiveTerminalSupport().supported;
495
496
  if (!isTTY && !opts.yes) {
496
497
  process.stderr.write('Error: apply push requires --yes in non-interactive mode\n');
497
498
  process.exit(1);
@@ -669,8 +670,10 @@ export function registerApplyCommand(program) {
669
670
  .option('--force', 'Skip confirmation for breaking changes')
670
671
  .action(async (opts) => {
671
672
  const nonInteractive = opts.selectAll || (opts.select ?? []).length > 0 || (opts.deselect ?? []).length > 0;
672
- if (!nonInteractive && !process.stdout.isTTY) {
673
- die('Error: apply select requires an interactive terminal unless --select-all, --select, or --deselect is provided');
673
+ if (!nonInteractive) {
674
+ requireInteractiveTerminal({
675
+ alternative: 'pass `--select-all`, `--select`, or `--deselect`',
676
+ });
674
677
  }
675
678
  let inputs;
676
679
  try {
@@ -11,6 +11,7 @@ import { pickerPushRun } from '../runs/push-launcher.js';
11
11
  import { resolvePromptFlags } from './print-prompt.js';
12
12
  import { shouldShowRunPicker } from '../runs/run-picker-mount.js';
13
13
  import { dispatchPickerSelection } from './picker-dispatch.js';
14
+ import { getInteractiveTerminalSupport, requireInteractiveTerminal } from '../lib/terminal-capabilities.js';
14
15
  export function registerImportCommand(program) {
15
16
  const cmd = program
16
17
  .command('import')
@@ -72,6 +73,7 @@ export function registerImportCommand(program) {
72
73
  .option('--save-as-new', 'Only valid with --modify: always save to a new path (prompts for one)')
73
74
  .option('--force', 'Bypass staleness checks when paired with --push-from-run or --modify.')
74
75
  .action(async (opts) => {
76
+ const interactiveTerminalSupported = getInteractiveTerminalSupport().supported;
75
77
  // --modify and --push-from-run resume a recorded session; the composition
76
78
  // mode comes from that run's record, so composition flags on the command
77
79
  // line don't apply. Warn and clear them rather than let them mislead.
@@ -130,7 +132,7 @@ export function registerImportCommand(program) {
130
132
  ...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
131
133
  ...(opts.cmaToken ? { cmaToken: opts.cmaToken } : {}),
132
134
  ...(opts.host ? { host: opts.host } : {}),
133
- interactive: !!process.stdout.isTTY,
135
+ interactive: interactiveTerminalSupported,
134
136
  ...(opts.force ? { force: true } : {}),
135
137
  });
136
138
  return;
@@ -152,6 +154,9 @@ export function registerImportCommand(program) {
152
154
  process.exit(1);
153
155
  return;
154
156
  }
157
+ requireInteractiveTerminal({
158
+ alternative: 'start a fresh headless import with `--yes` and the required credentials',
159
+ });
155
160
  try {
156
161
  await modifyRun({
157
162
  runIdOrPath: opts.modify,
@@ -221,7 +226,7 @@ export function registerImportCommand(program) {
221
226
  opts.skipGenerate ||
222
227
  // A "don't push" request on a non-TTY is a headless intent (the wizard
223
228
  // needs a TTY); in a TTY it stays interactive and is NOT headless.
224
- (noPushRequested && !process.stdout.isTTY) ||
229
+ (noPushRequested && !interactiveTerminalSupported) ||
225
230
  !!opts.spaceId ||
226
231
  !!opts.environmentId ||
227
232
  !!opts.cmaToken ||
@@ -229,12 +234,12 @@ export function registerImportCommand(program) {
229
234
  dryRunForward ||
230
235
  false;
231
236
  const autoAcceptScope = opts.autoAcceptScope ?? false;
232
- if (!process.stdout.isTTY && !isHeadless && !autoAcceptScope) {
233
- process.stderr.write('Error: experiences import is interactive. Pass --auto-accept-scope, or use a headless mode by providing credentials (--space-id, --environment-id, --cma-token) or one of --no-push, --skip-analyze, --skip-generate, --yes, --dry-run, --print-prompt.\n');
234
- process.exit(1);
235
- return;
237
+ if (!interactiveTerminalSupported && !isHeadless && !autoAcceptScope) {
238
+ requireInteractiveTerminal({
239
+ alternative: 'use headless flags such as `--yes` with credentials, or `--no-push --auto-accept-scope`',
240
+ });
236
241
  }
237
- if (process.stdout.isTTY && !isHeadless) {
242
+ if (interactiveTerminalSupported && !isHeadless) {
238
243
  const { render } = await import('ink');
239
244
  const { createElement } = await import('react');
240
245
  const { WizardApp } = await import('./tui/WizardApp.js');
@@ -300,7 +305,7 @@ export function registerImportCommand(program) {
300
305
  ...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
301
306
  ...(opts.cmaToken ? { cmaToken: opts.cmaToken } : {}),
302
307
  ...(opts.host ? { host: opts.host } : {}),
303
- interactive: !!process.stdout.isTTY,
308
+ interactive: true,
304
309
  ...(opts.outDir ? { outDir: opts.outDir } : {}),
305
310
  ...(opts.overwrite ? { overwrite: true } : {}),
306
311
  ...(opts.saveAsNew ? { saveAsNew: true } : {}),
@@ -37,7 +37,7 @@ import { ImportApiClient, ApiError } from '../../apply/api-client.js';
37
37
  import { detectSlotCycles, extractComponentsFromManifest, formatSlotCycleReport } from '../../apply/command.js';
38
38
  import { findSlotCycles } from '../../analyze/cycle-detection.js';
39
39
  import { buildComponentGraph } from '../../analyze/slot-graph.js';
40
- import { parseEdsiError, formatEdsiError, formatParsedEdsiError } from '../../lib/error-parser.js';
40
+ import { formatApiError, formatEdsiError } from '../../lib/error-parser.js';
41
41
  import { handlePreview422, applySkipValidationErrors, clearedValidationErrorState } from './wizard-422-helpers.js';
42
42
  import { parseGenerateStderrChunk } from './wizard-generate-progress.js';
43
43
  import { spawnGenerateChild } from './spawn-generate.js';
@@ -924,13 +924,11 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
924
924
  });
925
925
  return;
926
926
  }
927
+ const formatted = formatApiError(e, process.env['EDSI_VERBOSE_ERRORS'] === '1');
927
928
  update({
928
929
  step: 'error',
929
930
  errorStep: 'apply preview',
930
- errorMessage: formatParsedEdsiError(parseEdsiError(e.body || e.message), {
931
- verbose: process.env['EDSI_VERBOSE_ERRORS'] === '1',
932
- raw: e.body,
933
- }) || e.message,
931
+ errorMessage: formatted,
934
932
  errorAllowCredentialRetry: true,
935
933
  });
936
934
  return;
@@ -1092,13 +1090,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
1092
1090
  catch (e) {
1093
1091
  let msg;
1094
1092
  if (e instanceof ApiError) {
1095
- const parsed = parseEdsiError(e.body || e.message);
1096
- msg = formatParsedEdsiError(parsed, {
1097
- verbose: process.env['EDSI_VERBOSE_ERRORS'] === '1',
1098
- raw: e.body,
1099
- });
1100
- if (!msg)
1101
- msg = e.message;
1093
+ msg = formatApiError(e, process.env['EDSI_VERBOSE_ERRORS'] === '1');
1102
1094
  }
1103
1095
  else if (e instanceof Error) {
1104
1096
  msg = e.message;
@@ -23,6 +23,7 @@ export interface ErrorDiagnostic {
23
23
  }
24
24
  export interface ApiErrorLike {
25
25
  body?: string;
26
+ guidance?: string;
26
27
  message: string;
27
28
  }
28
29
  export declare function stripLambdaLogPrefix(body: string): string;
@@ -258,7 +258,6 @@ export function formatEdsiError(raw, opts = {}) {
258
258
  export function formatApiError(error, verbose = false) {
259
259
  const formatted = formatEdsiError(error.body || error.message, { verbose, raw: error.body }) || error.message;
260
260
  const phase = error.message.split('\n', 1)[0];
261
- return /^(?:apply|preview|poll) failed: \d+$/.test(phase) && formatted !== phase
262
- ? `${phase}\n${formatted}`
263
- : formatted;
261
+ const withPhase = /^(?:apply|preview|poll) failed: \d+$/.test(phase) && formatted !== phase ? `${phase}\n${formatted}` : formatted;
262
+ return error.guidance ? `${withPhase}\n${error.guidance}` : withPhase;
264
263
  }
@@ -0,0 +1,28 @@
1
+ export type TerminalInput = {
2
+ isTTY?: boolean;
3
+ isRaw?: boolean;
4
+ setRawMode?: (enabled: boolean) => unknown;
5
+ };
6
+ export type TerminalOutput = {
7
+ isTTY?: boolean;
8
+ };
9
+ export type InteractiveTerminalSupport = {
10
+ supported: true;
11
+ } | {
12
+ supported: false;
13
+ reason: 'input-not-tty' | 'output-not-tty' | 'raw-mode-unavailable';
14
+ };
15
+ export type TerminalCapabilityOptions = {
16
+ stdin?: TerminalInput;
17
+ stdout?: TerminalOutput;
18
+ };
19
+ /**
20
+ * Check the capabilities Ink's interactive input hooks require before the UI
21
+ * mounts. A stream can report itself as a TTY while its terminal host still
22
+ * rejects raw mode, so the check briefly enables and restores raw mode.
23
+ */
24
+ export declare function getInteractiveTerminalSupport(options?: TerminalCapabilityOptions): InteractiveTerminalSupport;
25
+ export type RequireInteractiveTerminalOptions = TerminalCapabilityOptions & {
26
+ alternative?: string;
27
+ };
28
+ export declare function requireInteractiveTerminal(options?: RequireInteractiveTerminalOptions): void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Check the capabilities Ink's interactive input hooks require before the UI
3
+ * mounts. A stream can report itself as a TTY while its terminal host still
4
+ * rejects raw mode, so the check briefly enables and restores raw mode.
5
+ */
6
+ export function getInteractiveTerminalSupport(options = {}) {
7
+ const stdin = options.stdin ?? process.stdin;
8
+ const stdout = options.stdout ?? process.stdout;
9
+ if (stdout.isTTY !== true)
10
+ return { supported: false, reason: 'output-not-tty' };
11
+ if (stdin.isTTY !== true)
12
+ return { supported: false, reason: 'input-not-tty' };
13
+ if (typeof stdin.setRawMode !== 'function') {
14
+ return { supported: false, reason: 'raw-mode-unavailable' };
15
+ }
16
+ const wasRaw = stdin.isRaw === true;
17
+ try {
18
+ stdin.setRawMode(true);
19
+ if (!wasRaw)
20
+ stdin.setRawMode(false);
21
+ }
22
+ catch {
23
+ if (!wasRaw) {
24
+ try {
25
+ stdin.setRawMode(false);
26
+ }
27
+ catch {
28
+ // The original capability failure is the actionable result.
29
+ }
30
+ }
31
+ return { supported: false, reason: 'raw-mode-unavailable' };
32
+ }
33
+ return { supported: true };
34
+ }
35
+ export function requireInteractiveTerminal(options = {}) {
36
+ const support = getInteractiveTerminalSupport(options);
37
+ if (support.supported)
38
+ return;
39
+ const reason = support.reason === 'output-not-tty'
40
+ ? 'standard output is not a TTY'
41
+ : support.reason === 'input-not-tty'
42
+ ? 'standard input is not a TTY'
43
+ : 'standard input cannot enter raw mode';
44
+ const lines = [
45
+ `Interactive terminal UI is unavailable because ${reason}.`,
46
+ 'On Windows, run this command in Windows Terminal with PowerShell.',
47
+ ];
48
+ if (options.alternative)
49
+ lines.push(`To continue without the UI, ${options.alternative}.`);
50
+ throw new Error(lines.join('\n'));
51
+ }
@@ -7,6 +7,7 @@ import { validateCDFFile } from './validate/validators/cdf-validator.js';
7
7
  import { validateDTCGTokenFile } from './validate/validators/dtcg-validator.js';
8
8
  import { formatDiagnostics } from './validate/validators/format-errors.js';
9
9
  import { ValidateView } from './validate/tui/ValidateView.js';
10
+ import { getInteractiveTerminalSupport } from '../lib/terminal-capabilities.js';
10
11
  function die(message) {
11
12
  process.stderr.write(`${message}\n`);
12
13
  process.exit(1);
@@ -195,7 +196,7 @@ export function registerPrintCommand(program) {
195
196
  }
196
197
  const failed = viewResults.some((r) => !r.valid);
197
198
  const exitCode = failed ? 1 : 0;
198
- if (process.stdout.isTTY) {
199
+ if (getInteractiveTerminalSupport().supported) {
199
200
  const { waitUntilExit } = render(createElement(ValidateView, {
200
201
  results: viewResults,
201
202
  onExit: () => process.exit(exitCode),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.16.1-dev-build-2b79e46.0",
3
+ "version": "2.16.1-dev-build-d068963.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,8 +34,8 @@
34
34
  "react": "^18.3.1",
35
35
  "react-devtools-core": "^4.19.1",
36
36
  "react-dom": "^18.3.1",
37
- "@contentful/experience-design-system-extraction": "2.16.1-dev-build-2b79e46.0",
38
- "@contentful/experience-design-system-types": "2.16.1-dev-build-2b79e46.0"
37
+ "@contentful/experience-design-system-extraction": "2.16.1-dev-build-d068963.0",
38
+ "@contentful/experience-design-system-types": "2.16.1-dev-build-d068963.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@tsconfig/node24": "^24.0.3",