@contentful/experience-design-system-cli 2.5.3-dev-build-727fd69.0 → 2.5.3-dev-build-985b472.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
@@ -1,10 +1,8 @@
1
1
  # @contentful/experience-design-system-cli
2
2
 
3
- CLI for extracting, reviewing, generating, validating, and pushing Contentful Experience Design System component definitions into [Experience Orchestration (ExO)](https://www.contentful.com/help/experience-orchestration/).
3
+ CLI for extracting, reviewing, generating, validating, and pushing Contentful Experience Design System component definitions into Experiences
4
4
 
5
- **Binary names:** `experiences` (primary), `exo` (alias), `experience-design-system-cli` (long-form). All three point to the same executable.
6
-
7
- ## Pipeline Overview
5
+ ## CLI Overview
8
6
 
9
7
  The commands form a pipeline. Run them in order, or use `import` to orchestrate the whole thing at once:
10
8
 
@@ -14,6 +12,8 @@ analyze extract → analyze select-agent → generate components →
14
12
 
15
13
  `analyze select-agent` uses an AI agent to decide which extracted components belong in Contentful Experience Orchestration. You can substitute it with `analyze select` for manual/pattern-based selection.
16
14
 
15
+ **Determinism boundary.** `analyze extract` is fully deterministic: ts-morph AST parsing produces the same component list and prop shape on every run, then a deterministic pre-classifier and a structural non-authorable filter (see below) shape the output. AI enters the pipeline at `analyze select-agent` and `generate components`, where coding agents make per-component decisions. This split keeps the extracted artifact reproducible and inspectable — if an extracted component looks wrong, the cause is in the rules, not in agent variability.
16
+
17
17
  All intermediate data flows through a local SQLite session database (`~/.contentful/experience-design-system-cli/pipeline.db`). No JSON files are written between steps — each command reads its inputs from the session and writes its outputs back to it. Use `print` to export session data to JSON files on demand (e.g. for inspection or manual validation).
18
18
 
19
19
  ---
@@ -93,6 +93,18 @@ Scans `.tsx`, `.ts`, `.jsx`, `.js`, `.vue`, and `.astro` files. Ignores `node_mo
93
93
 
94
94
  Writes extracted components to the session database and prints `session=<id>` to stdout. In an interactive terminal, a scrollable TUI displays the extraction summary (including a warning for any components with 0 props and 0 slots); press `q` or `Enter` to exit.
95
95
 
96
+ #### Non-authorable component filter
97
+
98
+ Before storing components, `analyze extract` runs a deterministic filter that drops infrastructure components which have no authoring surface (Context providers, analytics shims, security utilities, layout helpers). The filter uses prop-shape signals only — no component-name or source-path patterns — so it works regardless of how a host repo organizes its design system. A component is dropped if **any** of:
99
+
100
+ 1. Zero props and zero slots.
101
+ 2. Source calls `createContext()` and the component has a prop literally named `value`.
102
+ 3. Source calls `createContext()` and the component has zero props.
103
+ 4. Source calls `createContext()` and the component has exactly one non-handler prop.
104
+ 5. Every prop is a handler or ref (function-typed, `EventHandler`, `Dispatch`, `SetStateAction`, `Ref<>`, name starts with `on`/`set`, or named `ref`/`innerRef`).
105
+
106
+ Each dropped component is reported as a warning (`Skipped non-authorable component: <Name> (<reason>)`) so the operator can audit. The rule set was selected via Monte-Carlo evaluation against a hand-labelled corpus to maximize precision (zero false positives) over recall — components that look like normal authoring surface but are actually infrastructure are deferred to the AI selection stage rather than dropped here.
107
+
96
108
  ---
97
109
 
98
110
  ### `analyze select`
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.5.3-dev-build-727fd69.0",
3
+ "version": "2.5.3-dev-build-985b472.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,10 @@
18
18
  "node": "./dist/src/index.js"
19
19
  }
20
20
  },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://npm.pkg.github.com/"
24
+ },
21
25
  "files": [
22
26
  "bin/",
23
27
  "dist/",
@@ -8,6 +8,7 @@ import { registerAnalyzeEditCommand } from './select/command.js';
8
8
  import { registerAnalyzeSelectAgentCommand } from './select-agent/command.js';
9
9
  import { openPipelineDb, getOrCreateSession, createStep, updateStep, storeRawComponents } from '../session/db.js';
10
10
  import { preClassifyComponent } from './pre-classify.js';
11
+ import { isNonAuthorableComponent } from './extract/non-authorable-filter.js';
11
12
  const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
12
13
  const IGNORED_DIRECTORY_NAMES = new Set([
13
14
  '.git',
@@ -121,23 +122,32 @@ export function registerAnalyzeCommand(program) {
121
122
  });
122
123
  const stepId = createStep(db, sessionId, 'analyze extract', { project: projectRoot });
123
124
  const classifiedComponents = extraction.components.map(preClassifyComponent);
124
- storeRawComponents(db, sessionId, classifiedComponents);
125
+ const filteredComponents = [];
126
+ const filterWarnings = [];
127
+ for (const component of classifiedComponents) {
128
+ const verdict = isNonAuthorableComponent(component);
129
+ if (verdict.skip) {
130
+ filterWarnings.push(`Skipped non-authorable component: ${component.name} (${verdict.reason})`);
131
+ continue;
132
+ }
133
+ filteredComponents.push(component);
134
+ }
135
+ storeRawComponents(db, sessionId, filteredComponents);
125
136
  updateStep(db, stepId, 'complete', { sessionId });
126
137
  db.close();
127
- const zeroPropComponents = classifiedComponents.filter((c) => c.props.length === 0 && c.slots.length === 0);
138
+ const allWarnings = [...extraction.warnings, ...filterWarnings];
128
139
  const analyzeResult = {
129
140
  sourceDirectory,
130
141
  sessionId,
131
142
  fileCount: sourceFiles.length,
132
- components: classifiedComponents.map((c) => ({
143
+ components: filteredComponents.map((c) => ({
133
144
  name: c.name,
134
145
  framework: c.framework,
135
146
  propCount: c.props.length,
136
147
  slotCount: c.slots.length,
137
- warnings: extraction.warnings.filter((w) => w.startsWith(c.name + ':')),
148
+ warnings: allWarnings.filter((w) => w.startsWith(c.name + ':')),
138
149
  })),
139
- totalWarnings: extraction.warnings.length,
140
- zeroPropComponents: zeroPropComponents.map((c) => ({ name: c.name, source: c.source })),
150
+ totalWarnings: allWarnings.length,
141
151
  };
142
152
  if (process.stdout.isTTY) {
143
153
  const { waitUntilExit } = render(createElement(AnalyzeView, {
@@ -153,15 +163,9 @@ export function registerAnalyzeCommand(program) {
153
163
  `Scanned ${pluralize(sourceFiles.length, 'source file')} in ${sourceDirectory}`,
154
164
  `Extracted ${pluralize(extraction.components.length, 'component')}`,
155
165
  ];
156
- if (zeroPropComponents.length > 0) {
157
- summaryLines.push(`Warning: ${pluralize(zeroPropComponents.length, 'component')} extracted with 0 props and 0 slots:`);
158
- summaryLines.push(...zeroPropComponents.map((c) => ` ${c.name} (${c.source})`));
159
- summaryLines.push('These may be Storybook stories, context providers, or SSR utilities.');
160
- summaryLines.push("Review them in 'analyze select' before generating.");
161
- }
162
- if (extraction.warnings.length > 0) {
163
- summaryLines.push(`Warnings (${extraction.warnings.length}):`);
164
- summaryLines.push(...extraction.warnings.map((w) => `- ${w}`));
166
+ if (allWarnings.length > 0) {
167
+ summaryLines.push(`Warnings (${allWarnings.length}):`);
168
+ summaryLines.push(...allWarnings.map((w) => `- ${w}`));
165
169
  }
166
170
  else {
167
171
  summaryLines.push('Warnings: none');
@@ -0,0 +1,16 @@
1
+ import type { RawComponentDefinition } from '../../types.js';
2
+ export interface NonAuthorableResult {
3
+ skip: boolean;
4
+ reason?: string;
5
+ }
6
+ /**
7
+ * Decides whether a component is non-authorable infrastructure (Context.Provider,
8
+ * analytics shim, layout-only utility, etc.) and should be filtered out of the
9
+ * analyze TUI before authoring-token generation.
10
+ *
11
+ * Uses prop-shape signals only — no component-name or source-path patterns.
12
+ * A design system can live anywhere under any naming convention; relying on
13
+ * suffixes like `*Provider` or paths like `src/lib/` would silently fail in
14
+ * other repos.
15
+ */
16
+ export declare function isNonAuthorableComponent(component: RawComponentDefinition): NonAuthorableResult;
@@ -0,0 +1,60 @@
1
+ const HANDLER_TYPE_PATTERN = /=>|EventHandler|Dispatch<|SetStateAction/;
2
+ const REF_TYPE_PATTERN = /Ref<|RefObject<|MutableRefObject/;
3
+ function isHandlerOrRefProp(prop) {
4
+ if (HANDLER_TYPE_PATTERN.test(prop.type))
5
+ return true;
6
+ if (REF_TYPE_PATTERN.test(prop.type))
7
+ return true;
8
+ if (/^on[A-Z]/.test(prop.name) || /^set[A-Z]/.test(prop.name))
9
+ return true;
10
+ if (prop.name === 'ref' || prop.name === 'innerRef')
11
+ return true;
12
+ return false;
13
+ }
14
+ /**
15
+ * Decides whether a component is non-authorable infrastructure (Context.Provider,
16
+ * analytics shim, layout-only utility, etc.) and should be filtered out of the
17
+ * analyze TUI before authoring-token generation.
18
+ *
19
+ * Uses prop-shape signals only — no component-name or source-path patterns.
20
+ * A design system can live anywhere under any naming convention; relying on
21
+ * suffixes like `*Provider` or paths like `src/lib/` would silently fail in
22
+ * other repos.
23
+ */
24
+ export function isNonAuthorableComponent(component) {
25
+ const { props, slots, usesCreateContext } = component;
26
+ // R1: zero props AND zero slots — nothing for an editor to author.
27
+ // Catches analytics scripts, GTM tags, layout fixers, security tokens, etc.
28
+ if (props.length === 0 && slots.length === 0) {
29
+ return { skip: true, reason: 'component has no props and no slots' };
30
+ }
31
+ // R2: createContext source + prop literally named `value` — canonical
32
+ // `<Context.Provider value={...}>` call site.
33
+ if (usesCreateContext && props.some((p) => p.name === 'value')) {
34
+ return {
35
+ skip: true,
36
+ reason: 'source uses createContext and component exposes a Context.Provider value prop',
37
+ };
38
+ }
39
+ // R3: createContext source + zero props — Provider wrapper that hard-codes
40
+ // the context value internally (e.g. FontProvider, BottomSheetProvider).
41
+ if (usesCreateContext && props.length === 0) {
42
+ return { skip: true, reason: 'source uses createContext and component has no props' };
43
+ }
44
+ // R4: createContext source + exactly one non-handler prop — Provider that
45
+ // takes the context value as its sole data prop, named after the data
46
+ // (e.g. `LocaleProvider({ locale })`, `NavigationProvider({ navItems })`).
47
+ if (usesCreateContext && props.length === 1 && !isHandlerOrRefProp(props[0])) {
48
+ return {
49
+ skip: true,
50
+ reason: 'source uses createContext and component has a single non-handler prop',
51
+ };
52
+ }
53
+ // R5: every prop is a handler/ref — pure data plumbing, no authoring surface.
54
+ // Catches components like `OsanoCookiePlaceholder({ onBannerLoaded })` or
55
+ // `FeedbackCard({ setShowModal })`.
56
+ if (props.length > 0 && props.every(isHandlerOrRefProp)) {
57
+ return { skip: true, reason: 'every prop is a handler or ref' };
58
+ }
59
+ return { skip: false };
60
+ }
@@ -233,6 +233,14 @@ function getBoundedImportedJsxDomSurface(tagNameNode) {
233
233
  function isStencilFile(sourceFile) {
234
234
  return sourceFile.getImportDeclarations().some((imp) => imp.getModuleSpecifierValue() === '@stencil/core');
235
235
  }
236
+ function sourceFileUsesCreateContext(sourceFile) {
237
+ // Match React.createContext(...) and createContext(...) call expressions
238
+ return sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => {
239
+ const expr = call.getExpression();
240
+ const text = expr.getText();
241
+ return text === 'createContext' || text === 'React.createContext';
242
+ });
243
+ }
236
244
  function isNextJsComponent(filePath, exportedNames) {
237
245
  const normalized = filePath.replace(/\\/g, '/');
238
246
  const isAppRouterFile = /\/app\/.*\/(page|layout)\.[jt]sx?$/.test(normalized);
@@ -1634,6 +1642,7 @@ export async function extractReactComponents(filePaths) {
1634
1642
  function extractFromSourceFile(sourceFile, isNext) {
1635
1643
  const components = [];
1636
1644
  const exported = sourceFile.getExportedDeclarations();
1645
+ const usesCreateContext = sourceFileUsesCreateContext(sourceFile);
1637
1646
  for (const [exportKey, declarations] of exported) {
1638
1647
  let name = exportKey;
1639
1648
  if (exportKey === 'default') {
@@ -1668,6 +1677,7 @@ function extractFromSourceFile(sourceFile, isNext) {
1668
1677
  framework: isNext ? 'next' : 'react',
1669
1678
  props: [],
1670
1679
  slots: [],
1680
+ ...(usesCreateContext && { usesCreateContext: true }),
1671
1681
  });
1672
1682
  continue;
1673
1683
  }
@@ -1752,6 +1762,7 @@ function extractFromSourceFile(sourceFile, isNext) {
1752
1762
  framework: isNext ? 'next' : 'react',
1753
1763
  props: propsAfterSlotExpansion,
1754
1764
  slots: finalSlots,
1765
+ ...(usesCreateContext && { usesCreateContext: true }),
1755
1766
  });
1756
1767
  }
1757
1768
  return components;
@@ -11,10 +11,6 @@ export type AnalyzeViewResult = {
11
11
  warnings: string[];
12
12
  }>;
13
13
  totalWarnings: number;
14
- zeroPropComponents?: Array<{
15
- name: string;
16
- source: string;
17
- }>;
18
14
  };
19
15
  type AnalyzeViewProps = {
20
16
  result: AnalyzeViewResult;
@@ -31,7 +31,7 @@ export function AnalyzeView({ result, onExit }) {
31
31
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBar, { subcommand: "analyze", hints: [
32
32
  { key: '?', label: 'help' },
33
33
  { key: 'q', label: 'quit' },
34
- ] }), _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { children: 'Scanned ' + result.fileCount + ' source files in ' + result.sourceDirectory }), _jsx(Text, { children: 'Extracted ' + result.components.length + ' components' }), _jsx(Text, { dimColor: true, children: 'Session: ' + result.sessionId }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, children: "Components" }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components.slice(scrollOffset).map((component) => (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots') }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name))), result.zeroPropComponents && result.zeroPropComponents.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Zero-prop components (' + result.zeroPropComponents.length + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { color: "yellow", children: ' These may be Storybook stories, context providers, or SSR utilities.' }), _jsx(Text, { color: "yellow", children: ' Review them in `analyze select` before generating.' }), _jsx(Text, { children: " " }), result.zeroPropComponents.map((c) => (_jsx(Text, { color: "yellow", children: ' ' + c.name + ' (' + c.source + ')' }, c.name)))] })), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
34
+ ] }), _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { children: 'Scanned ' + result.fileCount + ' source files in ' + result.sourceDirectory }), _jsx(Text, { children: 'Extracted ' + result.components.length + ' components' }), _jsx(Text, { dimColor: true, children: 'Session: ' + result.sessionId }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, children: "Components" }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components.slice(scrollOffset).map((component) => (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots') }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name))), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
35
35
  .filter((c) => c.warnings.length > 0)
36
36
  .flatMap((c) => c.warnings.map((w) => ({ component: c.name, warning: w })))
37
37
  .map((w, i) => (_jsx(Text, { color: "yellow", children: ' ⚠ ' + w.component + ': ' + w.warning }, i)))] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: 'Run: analyze select --session ' + result.sessionId }), _jsx(Text, { children: " " })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or q to exit" }) })] }));
@@ -1,4 +1,5 @@
1
- export const DEFAULT_HOST = 'https://api.contentful.com';
1
+ import { DEFAULT_API_HOST, toApiHost } from '../host-utils.js';
2
+ export const DEFAULT_HOST = DEFAULT_API_HOST;
2
3
  export class ApiError extends Error {
3
4
  status;
4
5
  body;
@@ -33,7 +34,7 @@ export class ImportApiClient {
33
34
  spaceId;
34
35
  environmentId;
35
36
  constructor(opts) {
36
- this.host = opts.host ?? DEFAULT_HOST;
37
+ this.host = toApiHost(opts.host);
37
38
  this.token = opts.cmaToken;
38
39
  this.spaceId = opts.spaceId;
39
40
  this.environmentId = opts.environmentId;
@@ -1,5 +1,5 @@
1
- import { createElement } from 'react';
2
- import { render } from 'ink';
1
+ import { createElement, useState } from 'react';
2
+ import { render, useInput } from 'ink';
3
3
  import { access, readFile, readdir, stat } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { validateCDF, flattenDTCG, validateDTCG, buildManifest, buildFilteredManifest, } from '@contentful/experience-design-system-types';
@@ -7,8 +7,6 @@ import { ApiError, ImportApiClient } from './api-client.js';
7
7
  import { openPipelineDb, loadCDFComponents } from '../session/db.js';
8
8
  import { ServerPreviewApp, ServerPreviewConfirm, ServerApplyProgress, ServerApplyDone } from './tui/ServerApplyView.js';
9
9
  import { SelectView, makeSelectKey } from './tui/SelectView.js';
10
- import { useState } from 'react';
11
- import { useInput } from 'ink';
12
10
  function die(message) {
13
11
  process.stderr.write(`${message}\n`);
14
12
  process.exit(1);
@@ -2,6 +2,7 @@ export type ExperiencesCredentials = {
2
2
  spaceId: string;
3
3
  environmentId: string;
4
4
  cmaToken: string;
5
+ host?: string;
5
6
  };
6
7
  export declare function readExperiencesCredentials(): Promise<ExperiencesCredentials>;
7
8
  export declare function writeExperiencesCredentials(creds: ExperiencesCredentials): Promise<void>;
@@ -1,29 +1,39 @@
1
1
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { toConfiguredHost } from './host-utils.js';
4
5
  const CREDENTIALS_DIR = join(homedir(), '.config', 'experiences');
5
6
  const CREDENTIALS_PATH = join(CREDENTIALS_DIR, 'credentials.json');
6
7
  export async function readExperiencesCredentials() {
7
8
  try {
8
9
  const raw = await readFile(CREDENTIALS_PATH, 'utf8');
9
10
  const parsed = JSON.parse(raw);
11
+ const host = toConfiguredHost(process.env['EDS_HOST'] ?? parsed.host);
10
12
  return {
11
13
  spaceId: process.env['CONTENTFUL_SPACE_ID'] ?? parsed.spaceId ?? '',
12
14
  environmentId: process.env['CONTENTFUL_ENVIRONMENT_ID'] ?? parsed.environmentId ?? '',
13
15
  cmaToken: process.env['CONTENTFUL_MANAGEMENT_TOKEN'] ?? parsed.cmaToken ?? '',
16
+ ...(host ? { host } : {}),
14
17
  };
15
18
  }
16
19
  catch {
20
+ const host = toConfiguredHost(process.env['EDS_HOST']);
17
21
  return {
18
22
  spaceId: process.env['CONTENTFUL_SPACE_ID'] ?? '',
19
23
  environmentId: process.env['CONTENTFUL_ENVIRONMENT_ID'] ?? '',
20
24
  cmaToken: process.env['CONTENTFUL_MANAGEMENT_TOKEN'] ?? '',
25
+ ...(host ? { host } : {}),
21
26
  };
22
27
  }
23
28
  }
24
29
  export async function writeExperiencesCredentials(creds) {
30
+ const { host: _host, ...rest } = creds;
31
+ const host = toConfiguredHost(creds.host);
25
32
  await mkdir(CREDENTIALS_DIR, { recursive: true });
26
- await writeFile(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
33
+ await writeFile(CREDENTIALS_PATH, JSON.stringify({
34
+ ...rest,
35
+ ...(host ? { host } : {}),
36
+ }, null, 2) + '\n', { mode: 0o600 });
27
37
  }
28
38
  export function experiencesCredentialsPath() {
29
39
  return CREDENTIALS_PATH;
@@ -0,0 +1,4 @@
1
+ export declare const DEFAULT_API_HOST = "https://api.contentful.com";
2
+ export declare const DEFAULT_CONFIGURED_HOST = "api.contentful.com";
3
+ export declare function toConfiguredHost(host?: string): string | undefined;
4
+ export declare function toApiHost(host?: string): string;
@@ -0,0 +1,26 @@
1
+ export const DEFAULT_API_HOST = 'https://api.contentful.com';
2
+ export const DEFAULT_CONFIGURED_HOST = 'api.contentful.com';
3
+ function trimTrailingSlashes(value) {
4
+ return value.replace(/\/+$/, '');
5
+ }
6
+ function normalizeHostInput(host) {
7
+ const value = host?.trim();
8
+ if (!value)
9
+ return undefined;
10
+ return trimTrailingSlashes(value);
11
+ }
12
+ export function toConfiguredHost(host) {
13
+ const normalized = normalizeHostInput(host);
14
+ if (!normalized)
15
+ return undefined;
16
+ return normalized.replace(/^https:\/\//i, '');
17
+ }
18
+ export function toApiHost(host) {
19
+ const normalized = normalizeHostInput(host);
20
+ if (!normalized)
21
+ return DEFAULT_API_HOST;
22
+ if (/^[a-z][a-z\d+\-.]*:\/\//i.test(normalized)) {
23
+ return normalized;
24
+ }
25
+ return `https://${normalized}`;
26
+ }
@@ -1,6 +1,7 @@
1
1
  import { resolve, join } from 'node:path';
2
2
  import { runPipeline } from './orchestrator.js';
3
3
  import { readExperiencesCredentials } from '../credentials-store.js';
4
+ import { DEFAULT_CONFIGURED_HOST, toConfiguredHost } from '../host-utils.js';
4
5
  export function registerImportCommand(program) {
5
6
  program
6
7
  .command('import')
@@ -45,6 +46,7 @@ export function registerImportCommand(program) {
45
46
  initialSpaceId: creds.spaceId,
46
47
  initialEnvironmentId: creds.environmentId || 'master',
47
48
  initialCmaToken: creds.cmaToken,
49
+ initialHost: toConfiguredHost(opts.host ?? creds.host) ?? DEFAULT_CONFIGURED_HOST,
48
50
  initialAgent: opts.agent !== 'claude' ? opts.agent : undefined,
49
51
  initialProjectPath: opts.project !== '.' ? resolve(opts.project) : undefined,
50
52
  host: opts.host,
@@ -3,8 +3,9 @@ export type WizardAppProps = {
3
3
  initialSpaceId?: string;
4
4
  initialEnvironmentId?: string;
5
5
  initialCmaToken?: string;
6
+ initialHost?: string;
6
7
  initialAgent?: string;
7
8
  initialProjectPath?: string;
8
9
  host?: string;
9
10
  };
10
- export declare function WizardApp({ initialSpaceId, initialEnvironmentId, initialCmaToken, initialAgent, initialProjectPath, host, }?: WizardAppProps): React.ReactElement;
11
+ export declare function WizardApp({ initialSpaceId, initialEnvironmentId, initialCmaToken, initialHost, initialAgent, initialProjectPath, host, }?: WizardAppProps): React.ReactElement;
@@ -24,6 +24,8 @@ import { buildManifest } from '@contentful/experience-design-system-types';
24
24
  import { openPipelineDb, loadCDFComponents, seedCDFFromPreviewResponse, seedDefaultsFromChangedItems, backfillUnclassifiedProps, } from '../../session/db.js';
25
25
  import { checkAgentAuth } from '../../generate/agent-runner.js';
26
26
  import { normalizePath } from '../path-utils.js';
27
+ import { DEFAULT_CONFIGURED_HOST, toConfiguredHost } from '../../host-utils.js';
28
+ import { writeExperiencesCredentials } from '../../credentials-store.js';
27
29
  function findCliPath() {
28
30
  return join(fileURLToPath(import.meta.url), '..', '..', '..', '..', '..', 'bin', 'cli.js');
29
31
  }
@@ -39,8 +41,9 @@ function logStep(entry) {
39
41
  const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
40
42
  appendFileSync(WIZARD_LOG, line);
41
43
  }
42
- export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master', initialCmaToken = '', initialAgent, initialProjectPath, host, } = {}) {
43
- const apiHost = host ?? process.env['EDS_HOST'];
44
+ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master', initialCmaToken = '', initialHost, initialAgent, initialProjectPath, host, } = {}) {
45
+ const defaultConfiguredHost = toConfiguredHost(host || process.env['EDS_HOST']) ?? DEFAULT_CONFIGURED_HOST;
46
+ const resolveWizardHost = (hostValue) => hostValue || defaultConfiguredHost;
44
47
  const { stdout } = useStdout();
45
48
  const terminalWidth = stdout?.columns ?? 80;
46
49
  const logInit = useRef(false);
@@ -75,6 +78,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
75
78
  spaceId: initialSpaceId,
76
79
  environmentId: initialEnvironmentId,
77
80
  cmaToken: initialCmaToken,
81
+ host: resolveWizardHost(toConfiguredHost(initialHost)),
78
82
  credentialsError: '',
79
83
  serverPreview: null,
80
84
  manifest: null,
@@ -375,7 +379,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
375
379
  }
376
380
  if (returnToPreview) {
377
381
  const { extractSessionId, tokensPath } = sessionRef.current;
378
- void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken);
382
+ void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
379
383
  }
380
384
  else {
381
385
  advanceToPushFlow(generatedAcceptedCount);
@@ -431,19 +435,46 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
431
435
  }
432
436
  // Re-preview with updated definitions
433
437
  const { extractSessionId: sid, tokensPath: tp } = sessionRef.current;
434
- void runPreview(sid, tp, state.spaceId, state.environmentId, state.cmaToken);
438
+ void runPreview(sid, tp, state.spaceId, state.environmentId, state.cmaToken, state.host);
435
439
  };
436
- const confirmCredentials = (spaceId, environmentId, cmaToken) => {
440
+ const advanceWithCredentials = (spaceId, environmentId, cmaToken, host) => {
441
+ const resolvedHost = resolveWizardHost(host);
437
442
  credentialsRef.current = { spaceId, environmentId, cmaToken };
438
- update({ spaceId, environmentId, cmaToken, step: 'credential-test-gate' });
443
+ update({
444
+ spaceId,
445
+ environmentId,
446
+ cmaToken,
447
+ host: resolvedHost,
448
+ credentialsError: '',
449
+ step: 'credential-test-gate',
450
+ });
451
+ };
452
+ const confirmCredentials = async (spaceId, environmentId, cmaToken, host) => {
453
+ const resolvedHost = resolveWizardHost(host);
454
+ try {
455
+ await writeExperiencesCredentials({ spaceId, environmentId, cmaToken, host: resolvedHost });
456
+ advanceWithCredentials(spaceId, environmentId, cmaToken, resolvedHost);
457
+ }
458
+ catch (e) {
459
+ const message = e instanceof Error ? e.message : 'Unable to save credentials';
460
+ update({
461
+ spaceId,
462
+ environmentId,
463
+ cmaToken,
464
+ host: resolvedHost,
465
+ credentialsError: `Failed to save credentials: ${message}`,
466
+ step: 'credentials',
467
+ });
468
+ }
439
469
  };
440
- const validateCredentials = async (spaceId, environmentId, cmaToken) => {
470
+ const validateCredentials = async (spaceId, environmentId, cmaToken, host) => {
441
471
  update({ step: 'validating-credentials' });
442
472
  try {
443
- const client = new ImportApiClient({ cmaToken, spaceId, environmentId, host: apiHost });
473
+ const resolvedHost = resolveWizardHost(host);
474
+ const client = new ImportApiClient({ cmaToken, spaceId, environmentId, host: resolvedHost });
444
475
  await client.validateToken();
445
476
  const { extractSessionId, tokensPath } = sessionRef.current;
446
- void runPreview(extractSessionId, tokensPath, spaceId, environmentId, cmaToken);
477
+ void runPreview(extractSessionId, tokensPath, spaceId, environmentId, cmaToken, resolvedHost);
447
478
  }
448
479
  catch (e) {
449
480
  if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
@@ -459,10 +490,11 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
459
490
  });
460
491
  }
461
492
  };
462
- const runPreview = async (extractSessionId, tokensPath, spaceId, environmentId, cmaToken) => {
493
+ const runPreview = async (extractSessionId, tokensPath, spaceId, environmentId, cmaToken, host) => {
463
494
  update({ step: 'previewing' });
495
+ const resolvedHost = resolveWizardHost(host);
464
496
  try {
465
- const client = new ImportApiClient({ cmaToken, spaceId, environmentId, host: apiHost });
497
+ const client = new ImportApiClient({ cmaToken, spaceId, environmentId, host: resolvedHost });
466
498
  let components = [];
467
499
  if (extractSessionId) {
468
500
  const db = openPipelineDb();
@@ -548,7 +580,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
548
580
  errorMessage: `Not found (404). Check that the space ID, environment ID, and host are correct.\n\n` +
549
581
  ` Space: ${spaceId}\n` +
550
582
  ` Environment: ${environmentId}\n` +
551
- (apiHost ? ` Host: ${apiHost}\n` : '') +
583
+ (resolvedHost ? ` Host: ${resolvedHost}\n` : '') +
552
584
  `\nIf using a custom --host, make sure the space exists on that host.`,
553
585
  });
554
586
  return;
@@ -560,7 +592,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
560
592
  update({ step: 'error', errorStep: 'apply preview', errorMessage: msg, errorAllowCredentialRetry: true });
561
593
  }
562
594
  };
563
- const runPush = async (manifest, spaceId, environmentId, cmaToken, acknowledgeBreakingChanges, preview) => {
595
+ const runPush = async (manifest, spaceId, environmentId, cmaToken, host, acknowledgeBreakingChanges, preview) => {
564
596
  if (preview) {
565
597
  const hasComponentChanges = preview.components.new.length > 0 ||
566
598
  preview.components.changed.length > 0 ||
@@ -579,7 +611,8 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
579
611
  }
580
612
  update({ step: 'pushing' });
581
613
  try {
582
- const client = new ImportApiClient({ cmaToken, spaceId, environmentId, host: apiHost });
614
+ const resolvedHost = resolveWizardHost(host);
615
+ const client = new ImportApiClient({ cmaToken, spaceId, environmentId, host: resolvedHost });
583
616
  let operation = await client.applyImport(manifest, acknowledgeBreakingChanges);
584
617
  try {
585
618
  logStep({
@@ -835,13 +868,15 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
835
868
  }, onQuit: () => process.exit(0) }));
836
869
  }
837
870
  case 'credentials':
838
- return (_jsx(CredentialsStep, { initialSpaceId: state.spaceId, initialEnvironmentId: state.environmentId, initialCmaToken: state.cmaToken, error: state.credentialsError || undefined, onConfirm: confirmCredentials, onContinue: confirmCredentials, onQuit: () => process.exit(0) }));
871
+ return (_jsx(CredentialsStep, { initialSpaceId: state.spaceId, initialEnvironmentId: state.environmentId, initialCmaToken: state.cmaToken, initialHost: state.host, error: state.credentialsError || undefined, onConfirm: (spaceId, environmentId, cmaToken, host) => {
872
+ void confirmCredentials(spaceId, environmentId, cmaToken, host);
873
+ }, onContinue: advanceWithCredentials, onQuit: () => process.exit(0) }));
839
874
  case 'credential-test-gate':
840
875
  return (_jsx(GateStep, { successMessage: "Credentials entered", summary: `Space: ${state.spaceId} · Environment: ${state.environmentId}`, context: "Verify your credentials work before running the import, or skip and find out during the push step.", continueLabel: "Test credentials", skipLabel: "Skip and continue", showSkip: true, onContinue: () => {
841
- void validateCredentials(state.spaceId, state.environmentId, state.cmaToken);
876
+ void validateCredentials(state.spaceId, state.environmentId, state.cmaToken, state.host);
842
877
  }, onSkip: () => {
843
878
  const { extractSessionId, tokensPath } = sessionRef.current;
844
- void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken);
879
+ void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
845
880
  }, onQuit: () => process.exit(0) }));
846
881
  case 'validating-credentials':
847
882
  return (_jsx(RunningStep, { stepNumber: totalSteps - 1, totalSteps: totalSteps, title: "Validating credentials", description: "Checking that your space ID and CMA token are valid..." }));
@@ -849,7 +884,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
849
884
  return (_jsx(RunningStep, { stepNumber: totalSteps, totalSteps: totalSteps, title: "Computing diff", description: "Computing diff against your Contentful space..." }));
850
885
  case 'preview-gate':
851
886
  return (_jsx(WizardPreviewStep, { preview: state.serverPreview, spaceId: state.spaceId, environmentId: state.environmentId, stepNumber: totalSteps, totalSteps: totalSteps, onConfirm: (acknowledge) => {
852
- void runPush(state.manifest, state.spaceId, state.environmentId, state.cmaToken, acknowledge, state.serverPreview);
887
+ void runPush(state.manifest, state.spaceId, state.environmentId, state.cmaToken, state.host, acknowledge, state.serverPreview);
853
888
  }, onEdit: () => {
854
889
  void runEditFromPreview(state.serverPreview);
855
890
  }, onSaveFiles: () => {
@@ -5,11 +5,12 @@ type CredentialsStepProps = {
5
5
  initialSpaceId?: string;
6
6
  initialEnvironmentId?: string;
7
7
  initialCmaToken?: string;
8
- /** Called when the user submits with all fields changed from their initial values */
9
- onConfirm: (spaceId: string, environmentId: string, cmaToken: string) => void;
8
+ initialHost?: string;
9
+ /** Called when the user submits with any field changed from its initial value */
10
+ onConfirm: (spaceId: string, environmentId: string, cmaToken: string, host: string) => void;
10
11
  /** Called when the user submits without changing any field (use existing creds as-is) */
11
- onContinue?: (spaceId: string, environmentId: string, cmaToken: string) => void;
12
+ onContinue?: (spaceId: string, environmentId: string, cmaToken: string, host: string) => void;
12
13
  onQuit: () => void;
13
14
  };
14
- export declare function CredentialsStep({ summary, error: externalError, initialSpaceId, initialEnvironmentId, initialCmaToken, onConfirm, onContinue, onQuit, }: CredentialsStepProps): React.ReactElement;
15
+ export declare function CredentialsStep({ summary, error: externalError, initialSpaceId, initialEnvironmentId, initialCmaToken, initialHost, onConfirm, onContinue, onQuit, }: CredentialsStepProps): React.ReactElement;
15
16
  export {};
@@ -2,10 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
5
- export function CredentialsStep({ summary, error: externalError, initialSpaceId = '', initialEnvironmentId = 'master', initialCmaToken = '', onConfirm, onContinue, onQuit, }) {
5
+ import { DEFAULT_CONFIGURED_HOST, toConfiguredHost } from '../../../host-utils.js';
6
+ export function CredentialsStep({ summary, error: externalError, initialSpaceId = '', initialEnvironmentId = 'master', initialCmaToken = '', initialHost, onConfirm, onContinue, onQuit, }) {
7
+ const normalizedInitialHost = toConfiguredHost(initialHost) ?? DEFAULT_CONFIGURED_HOST;
6
8
  const [spaceId, setSpaceId] = useState(initialSpaceId);
7
9
  const [environmentId, setEnvironmentId] = useState(initialEnvironmentId);
8
10
  const [cmaToken, setCmaToken] = useState(initialCmaToken);
11
+ const [host, setHost] = useState(normalizedInitialHost);
9
12
  const [activeField, setActiveField] = useState('spaceId');
10
13
  const [inlineError, setInlineError] = useState(null);
11
14
  const [cursorVisible, setCursorVisible] = useState(true);
@@ -23,25 +26,31 @@ export function CredentialsStep({ summary, error: externalError, initialSpaceId
23
26
  setActiveField('cmaToken');
24
27
  return;
25
28
  }
29
+ if (activeField === 'cmaToken') {
30
+ setActiveField('host');
31
+ return;
32
+ }
26
33
  // Submit
27
34
  if (!spaceId.trim() || !environmentId.trim() || !cmaToken.trim()) {
28
35
  setInlineError('All fields are required.');
29
36
  return;
30
37
  }
31
38
  setInlineError(null);
39
+ const submittedHost = toConfiguredHost(host) ?? DEFAULT_CONFIGURED_HOST;
32
40
  const unchanged = spaceId.trim() === initialSpaceId &&
33
41
  environmentId.trim() === initialEnvironmentId &&
34
- cmaToken.trim() === initialCmaToken;
42
+ cmaToken.trim() === initialCmaToken &&
43
+ submittedHost === normalizedInitialHost;
35
44
  if (unchanged && onContinue) {
36
- onContinue(spaceId.trim(), environmentId.trim(), cmaToken.trim());
45
+ onContinue(spaceId.trim(), environmentId.trim(), cmaToken.trim(), submittedHost);
37
46
  }
38
47
  else {
39
- onConfirm(spaceId.trim(), environmentId.trim(), cmaToken.trim());
48
+ onConfirm(spaceId.trim(), environmentId.trim(), cmaToken.trim(), submittedHost);
40
49
  }
41
50
  return;
42
51
  }
43
52
  if (key.tab) {
44
- setActiveField((f) => (f === 'spaceId' ? 'environmentId' : f === 'environmentId' ? 'cmaToken' : 'spaceId'));
53
+ setActiveField((f) => f === 'spaceId' ? 'environmentId' : f === 'environmentId' ? 'cmaToken' : f === 'cmaToken' ? 'host' : 'spaceId');
45
54
  return;
46
55
  }
47
56
  if (key.escape || input === 'q') {
@@ -53,8 +62,10 @@ export function CredentialsStep({ summary, error: externalError, initialSpaceId
53
62
  setSpaceId((v) => v.slice(0, -1));
54
63
  else if (activeField === 'environmentId')
55
64
  setEnvironmentId((v) => v.slice(0, -1));
56
- else
65
+ else if (activeField === 'cmaToken')
57
66
  setCmaToken((v) => v.slice(0, -1));
67
+ else
68
+ setHost((v) => v.slice(0, -1));
58
69
  return;
59
70
  }
60
71
  if (input && !key.ctrl && !key.meta) {
@@ -62,18 +73,21 @@ export function CredentialsStep({ summary, error: externalError, initialSpaceId
62
73
  setSpaceId((v) => v + input);
63
74
  else if (activeField === 'environmentId')
64
75
  setEnvironmentId((v) => v + input);
65
- else
76
+ else if (activeField === 'cmaToken')
66
77
  setCmaToken((v) => v + input);
78
+ else
79
+ setHost((v) => v + input);
67
80
  }
68
81
  });
69
82
  const cursor = cursorVisible ? '█' : ' ';
70
83
  function renderField(label, value, field, masked = false) {
71
84
  const isActive = activeField === field;
72
85
  const display = masked ? '•'.repeat(value.length) : value;
73
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isActive ? 'cyan' : undefined, children: '?' }), _jsxs(Text, { bold: isActive, children: [label, ":"] }), _jsx(Text, { children: isActive ? display + cursor : display || _jsx(Text, { dimColor: true, children: "(empty)" }) })] }));
86
+ const fallback = field === 'host' ? DEFAULT_CONFIGURED_HOST : _jsx(Text, { dimColor: true, children: "(empty)" });
87
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isActive ? 'cyan' : undefined, children: '?' }), _jsxs(Text, { bold: isActive, children: [label, ":"] }), _jsx(Text, { children: isActive ? display + cursor : display || fallback })] }));
74
88
  }
75
89
  const displayError = inlineError ?? externalError ?? null;
76
90
  return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [summary && _jsxs(Text, { color: "green", children: ["\u2713 ", summary] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: initialSpaceId && initialCmaToken
77
91
  ? 'Credentials pre-filled from experiences setup. Press Enter to continue or edit any field to update.'
78
- : 'Enter your Contentful credentials to continue.' }) }), !(initialSpaceId && initialCmaToken) && (_jsx(Text, { dimColor: true, children: "Tip: run experiences setup to save these to ~/.config/experiences/credentials.json so they pre-fill here automatically." })), _jsxs(Box, { flexDirection: "column", gap: 0, marginTop: 1, children: [renderField('Space ID', spaceId, 'spaceId'), renderField('Environment', environmentId, 'environmentId'), renderField('CMA Token', cmaToken, 'cmaToken', true)] }), displayError && _jsxs(Text, { color: "red", children: ["\u2717 ", displayError] }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Next field / Submit" }), _jsx(Text, { dimColor: true, children: "[Tab] Switch field" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
92
+ : 'Enter your Contentful credentials to continue.' }) }), !(initialSpaceId && initialCmaToken) && (_jsx(Text, { dimColor: true, children: "Tip: run experiences setup to save these to ~/.config/experiences/credentials.json so they pre-fill here automatically." })), _jsxs(Box, { flexDirection: "column", gap: 0, marginTop: 1, children: [renderField('Space ID', spaceId, 'spaceId'), renderField('Environment', environmentId, 'environmentId'), renderField('CMA Token', cmaToken, 'cmaToken', true), renderField('API Host', host, 'host')] }), activeField === 'host' && _jsx(Text, { dimColor: true, children: "Default: api.contentful.com \u00B7 EU spaces: api.eu.contentful.com" }), displayError && _jsxs(Text, { color: "red", children: ["\u2717 ", displayError] }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Next field / Submit" }), _jsx(Text, { dimColor: true, children: "[Tab] Switch field" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
79
93
  }
package/dist/src/index.js CHANGED
@@ -1,2 +1,7 @@
1
1
  import { createProgram } from './program.js';
2
- await createProgram().parseAsync();
2
+ createProgram()
3
+ .parseAsync()
4
+ .catch((err) => {
5
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
6
+ process.exit(1);
7
+ });
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { promisify } from 'node:util';
8
8
  import { readExperiencesCredentials, writeExperiencesCredentials, experiencesCredentialsPath, } from '../credentials-store.js';
9
+ import { DEFAULT_CONFIGURED_HOST, toConfiguredHost } from '../host-utils.js';
9
10
  const execFileAsync = promisify(execFile);
10
11
  const REQUIRED_NODE_MAJOR = 24;
11
12
  // ── Output helpers ────────────────────────────────────────────────────────────
@@ -29,7 +30,13 @@ function dim(msg) {
29
30
  process.stdout.write(`\x1b[2m${msg}\x1b[0m\n`);
30
31
  }
31
32
  // ── Prompt helpers ────────────────────────────────────────────────────────────
33
+ function isInteractivePromptSession() {
34
+ return !!(process.stdin.isTTY && process.stdout.isTTY);
35
+ }
32
36
  function prompt(question) {
37
+ if (!isInteractivePromptSession()) {
38
+ return Promise.resolve('');
39
+ }
33
40
  const rl = createInterface({ input: process.stdin, output: process.stdout });
34
41
  return new Promise((resolve) => {
35
42
  rl.question(question, (answer) => {
@@ -39,6 +46,9 @@ function prompt(question) {
39
46
  });
40
47
  }
41
48
  function promptSecret(question) {
49
+ if (!isInteractivePromptSession()) {
50
+ return Promise.resolve('');
51
+ }
42
52
  // Use readline for all prompts — mixing raw-mode stdin listeners with
43
53
  // readline createInterface causes readline to buffer+unshift unconsumed
44
54
  // input back onto the stream, which the raw listener then re-reads,
@@ -51,9 +61,10 @@ function promptSecret(question) {
51
61
  });
52
62
  let value = '';
53
63
  process.stdout.write(question);
64
+ let origWrite = null;
54
65
  if (process.stdin.isTTY) {
55
66
  // Intercept the readline output write so we can replace echoed chars with *
56
- const origWrite = rl.output.write.bind(rl.output);
67
+ origWrite = rl.output.write.bind(rl.output);
57
68
  rl.output.write = (s) => {
58
69
  // Allow newline through; suppress everything else (the echoed characters)
59
70
  if (s === '\r\n' || s === '\n' || s === '\r')
@@ -65,12 +76,24 @@ function promptSecret(question) {
65
76
  rl.close();
66
77
  });
67
78
  rl.once('close', () => {
68
- process.stdout.write('\n');
79
+ // Restore stdout.write before resolving — the interceptor patches rl.output.write
80
+ // which is process.stdout.write, so without restoring it all subsequent output is swallowed.
81
+ if (origWrite) {
82
+ rl.output.write = origWrite;
83
+ }
84
+ // In TTY mode readline already emitted \n when Enter was pressed; only add one in non-TTY.
85
+ if (!process.stdin.isTTY)
86
+ process.stdout.write('\n');
87
+ // rl.close() pauses stdin; resume it so subsequent prompt() calls work.
88
+ process.stdin.resume();
69
89
  resolve(value);
70
90
  });
71
91
  });
72
92
  }
73
93
  async function confirm(question, defaultYes = true) {
94
+ if (!isInteractivePromptSession()) {
95
+ return false;
96
+ }
74
97
  const hint = defaultYes ? '[Y/n]' : '[y/N]';
75
98
  const answer = await prompt(` ${question} ${hint} `);
76
99
  if (!answer)
@@ -387,6 +410,8 @@ async function setupContentfulCredentials() {
387
410
  const currentSpace = stored.spaceId;
388
411
  const currentEnv = stored.environmentId;
389
412
  const currentToken = stored.cmaToken;
413
+ const storedHost = stored.host;
414
+ const currentHost = storedHost ?? DEFAULT_CONFIGURED_HOST;
390
415
  const hasAny = !!(currentSpace || currentEnv || currentToken);
391
416
  if (hasAny) {
392
417
  info('Current values:');
@@ -408,6 +433,7 @@ async function setupContentfulCredentials() {
408
433
  else {
409
434
  warn('CMA Token (not set)');
410
435
  }
436
+ ok(`API Host ${currentHost}`);
411
437
  info('');
412
438
  }
413
439
  const allSet = !!(currentSpace && currentEnv && currentToken);
@@ -434,9 +460,12 @@ async function setupContentfulCredentials() {
434
460
  warn('Space ID and CMA token are required. Skipped.');
435
461
  return false;
436
462
  }
437
- await writeExperiencesCredentials({ spaceId, environmentId, cmaToken });
463
+ const hostInput = await prompt(` API host [${currentHost}]: `);
464
+ const host = toConfiguredHost(hostInput) ?? storedHost;
465
+ await writeExperiencesCredentials({ spaceId, environmentId, cmaToken, ...(host ? { host } : {}) });
438
466
  ok(`Credentials saved to ${experiencesCredentialsPath()}`);
439
- info('Run experiences import the credentials step will be pre-filled automatically.');
467
+ ok(`API host set to ${host ?? DEFAULT_CONFIGURED_HOST}`);
468
+ info('Run experiences import — credentials will be pre-filled automatically.');
440
469
  return true;
441
470
  }
442
471
  // ── Step 6: Optional quality-of-life ─────────────────────────────────────────
@@ -28,6 +28,12 @@ export interface RawComponentDefinition {
28
28
  framework: 'react' | 'next' | 'vue' | 'astro' | 'web-component' | 'stencil';
29
29
  props: RawPropDefinition[];
30
30
  slots: RawSlotDefinition[];
31
+ /**
32
+ * True when the source file declaring this component calls
33
+ * React.createContext / createContext. Used downstream to filter
34
+ * non-authorable context-provider components.
35
+ */
36
+ usesCreateContext?: boolean;
31
37
  }
32
38
  export interface ComponentExtractionResult {
33
39
  components: RawComponentDefinition[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.5.3-dev-build-727fd69.0",
3
+ "version": "2.5.3-dev-build-985b472.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,10 @@
18
18
  "node": "./dist/src/index.js"
19
19
  }
20
20
  },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://npm.pkg.github.com/"
24
+ },
21
25
  "files": [
22
26
  "bin/",
23
27
  "dist/",
@@ -32,7 +36,7 @@
32
36
  "react-dom": "^18.3.1",
33
37
  "ts-morph": "^27.0.2",
34
38
  "typescript": "^5.9.3",
35
- "@contentful/experience-design-system-types": "2.5.3-dev-build-727fd69.0"
39
+ "@contentful/experience-design-system-types": "2.5.3-dev-build-985b472.0"
36
40
  },
37
41
  "devDependencies": {
38
42
  "@tsconfig/node24": "^24.0.3",