@astryxdesign/cli 0.1.7-canary.fb9c68c → 0.1.7

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 (52) hide show
  1. package/README.md +19 -115
  2. package/docs/getting-started.doc.mjs +9 -9
  3. package/docs/integration-authoring.md +105 -0
  4. package/docs/migration.doc.mjs +18 -18
  5. package/docs/principles.doc.dense.mjs +1 -1
  6. package/docs/principles.doc.mjs +6 -6
  7. package/docs/principles.doc.zh.mjs +1 -1
  8. package/docs/styling-libraries.doc.mjs +3 -3
  9. package/docs/styling.doc.mjs +4 -4
  10. package/docs/theme.doc.dense.mjs +2 -2
  11. package/docs/theme.doc.mjs +7 -7
  12. package/docs/theme.doc.zh.mjs +1 -1
  13. package/docs/tokens.doc.mjs +1 -1
  14. package/docs/working-with-ai.doc.mjs +18 -18
  15. package/package.json +10 -11
  16. package/src/api/doctor.mjs +3 -3
  17. package/src/codemods/ensure-jscodeshift.mjs +27 -11
  18. package/src/codemods/run-codemod.mjs +1 -1
  19. package/src/codemods/runner.mjs +2 -2
  20. package/src/commands/agent-docs.mjs +14 -56
  21. package/src/commands/agent-docs.test.mjs +4 -11
  22. package/src/commands/build-theme.mjs +71 -10
  23. package/src/commands/build.mjs +15 -15
  24. package/src/commands/component/index.mjs +4 -4
  25. package/src/commands/discover.mjs +5 -7
  26. package/src/commands/docs.mjs +4 -4
  27. package/src/commands/hook/index.mjs +4 -4
  28. package/src/commands/init.mjs +152 -48
  29. package/src/commands/init.next-steps.test.mjs +1 -1
  30. package/src/commands/interactive-guard.test.mjs +22 -19
  31. package/src/commands/json-contract.test.mjs +1 -1
  32. package/src/commands/layout.mjs +1 -1
  33. package/src/commands/search.mjs +4 -4
  34. package/src/commands/swizzle.mjs +34 -11
  35. package/src/commands/template.mjs +31 -11
  36. package/src/commands/upgrade.mjs +6 -9
  37. package/src/commands/upgrade.test.mjs +1 -1
  38. package/src/index.mjs +6 -40
  39. package/src/lib/component-format.mjs +1 -2
  40. package/src/utils/interactive.mjs +76 -0
  41. package/src/utils/interactive.test.mjs +70 -0
  42. package/src/utils/package-manager.mjs +0 -78
  43. package/src/utils/package-manager.test.mjs +1 -108
  44. package/src/utils/path-safety.mjs +18 -0
  45. package/src/utils/update-check.mjs +1 -2
  46. package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
  47. package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +7 -2
  48. package/docs/cli-integrations.doc.mjs +0 -150
  49. package/scripts/postinstall.mjs +0 -74
  50. package/src/commands/cli-postinstall.test.mjs +0 -42
  51. package/src/commands/setup-nudge.test.mjs +0 -108
  52. package/src/lib/term-log.mjs +0 -48
@@ -15,16 +15,18 @@
15
15
 
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
+ import * as p from '@clack/prompts';
18
19
  import {findCoreDir, listComponents} from '../utils/paths.mjs';
19
20
  import {
20
21
  assertWithin,
21
22
  PathSafetyError,
23
+ isNonInteractive,
22
24
  } from '../utils/path-safety.mjs';
23
25
  import {jsonOut, humanLog} from '../lib/json.mjs';
24
26
  import {cliError} from '../lib/cli-error.mjs';
25
27
  import {ERROR_CODES} from '../lib/error-codes.mjs';
26
28
  import {checkGhCli} from '../utils/github.mjs';
27
- import {getCliInvocation} from '../utils/package-manager.mjs';
29
+ import {getRunPrefix} from '../utils/package-manager.mjs';
28
30
  import {Project} from '../lib/project.mjs';
29
31
  import {
30
32
  CORE_PACKAGE,
@@ -97,6 +99,14 @@ function buildFeedback(component, issuesUrl) {
97
99
  return feedback;
98
100
  }
99
101
 
102
+ function isCancel(value) {
103
+ if (p.isCancel(value)) {
104
+ p.cancel('Cancelled.');
105
+ process.exit(0);
106
+ }
107
+ return value;
108
+ }
109
+
100
110
  /**
101
111
  * Load the configured integrations + core issues URL for `cwd`, swallowing any
102
112
  * config errors so swizzle never hard-fails on a malformed/absent config. An
@@ -180,7 +190,6 @@ export function registerSwizzle(program) {
180
190
  .action(async (component, options) => {
181
191
  const coreDir = findCoreDir(process.cwd());
182
192
  const json = program.opts().json || false;
183
- const run = getCliInvocation();
184
193
 
185
194
  if (!coreDir) {
186
195
  cliError(
@@ -198,10 +207,10 @@ export function registerSwizzle(program) {
198
207
  for (const name of components) {
199
208
  humanLog(` ${name}`);
200
209
  }
201
- humanLog(`\nUsage: ${run} swizzle <component>\n`);
202
- humanLog(`Example: ${run} swizzle Button`);
210
+ humanLog(`\nUsage: astryx swizzle <component>\n`);
211
+ humanLog('Example: astryx swizzle Button');
203
212
  humanLog(
204
- ` ${run} swizzle XDSButton (XDS prefix also works)\n`,
213
+ ' astryx swizzle XDSButton (XDS prefix also works)\n',
205
214
  );
206
215
  return;
207
216
  }
@@ -305,11 +314,25 @@ export function registerSwizzle(program) {
305
314
 
306
315
  if (existingFiles.length > 0 && !options.overwrite) {
307
316
  const relOutputForMsg = path.relative(process.cwd(), outputDir) || '.';
308
- const msg =
309
- `Refusing to overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/. ` +
310
- `Re-run with --overwrite (or -f) to replace them.`;
311
- cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
312
- return;
317
+ if (json || isNonInteractive({json})) {
318
+ const msg =
319
+ `Refusing to overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/. ` +
320
+ `Re-run with --overwrite (or -f) to replace them.`;
321
+ cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
322
+ return;
323
+ }
324
+ const confirmed = isCancel(
325
+ await p.confirm({
326
+ message:
327
+ `Overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/? ` +
328
+ `(${existingFiles.slice(0, 3).join(', ')}${existingFiles.length > 3 ? ', …' : ''})`,
329
+ initialValue: false,
330
+ }),
331
+ );
332
+ if (!confirmed) {
333
+ humanLog('Aborted. Re-run with --overwrite to replace files.');
334
+ return;
335
+ }
313
336
  }
314
337
 
315
338
  fs.mkdirSync(outputDir, {recursive: true});
@@ -388,7 +411,7 @@ export function registerSwizzle(program) {
388
411
  humanLog(
389
412
  ' Without one they render unstyled (no error). See setup per framework:',
390
413
  );
391
- humanLog(` ${run} docs styling`);
414
+ humanLog(` ${getRunPrefix()} astryx docs styling`);
392
415
  humanLog(
393
416
  ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font —',
394
417
  );
@@ -6,16 +6,25 @@
6
6
 
7
7
  import * as path from 'node:path';
8
8
  import * as fs from 'node:fs';
9
+ import * as p from '@clack/prompts';
10
+ import {isNonInteractive} from '../utils/path-safety.mjs';
9
11
  import {jsonOut, humanLog} from '../lib/json.mjs';
10
12
  import {cliError} from '../lib/cli-error.mjs';
11
13
  import {ERROR_CODES} from '../lib/error-codes.mjs';
12
14
  import {template as templateApi} from '../api/template.mjs';
13
15
  import {Project} from '../lib/project.mjs';
14
16
  import {warnOnIntegrationIssues} from '../lib/integration-warnings.mjs';
15
- import {getCliInvocation} from '../utils/package-manager.mjs';
16
17
 
17
18
  export {discoverTemplates, listTemplates} from '../api/template.mjs';
18
19
 
20
+ function isCancel(value) {
21
+ if (p.isCancel(value)) {
22
+ p.cancel('Cancelled.');
23
+ process.exit(0);
24
+ }
25
+ return value;
26
+ }
27
+
19
28
  export function registerTemplate(program) {
20
29
  program
21
30
  .command('template [name] [path]')
@@ -27,7 +36,6 @@ export function registerTemplate(program) {
27
36
  .option('-f, --overwrite', 'Overwrite existing files without prompting')
28
37
  .action(async (name, targetPath, options) => {
29
38
  const json = program.opts().json || false;
30
- const run = getCliInvocation();
31
39
 
32
40
  // Non-blocking nudge: if any configured integration has validation
33
41
  // issues, print one compact line to stderr pointing at
@@ -51,11 +59,23 @@ export function registerTemplate(program) {
51
59
  const collision = await detectTemplateCollision(name, targetPath);
52
60
  if (collision && !options.overwrite) {
53
61
  const rel = path.relative(process.cwd(), collision) || collision;
54
- const msg =
55
- `Refusing to overwrite existing file ${rel}. ` +
56
- `Re-run with --overwrite (or -f) to replace it.`;
57
- cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
58
- return;
62
+ if (json || isNonInteractive({json})) {
63
+ const msg =
64
+ `Refusing to overwrite existing file ${rel}. ` +
65
+ `Re-run with --overwrite (or -f) to replace it.`;
66
+ cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
67
+ return;
68
+ }
69
+ const confirmed = isCancel(
70
+ await p.confirm({
71
+ message: `Overwrite existing file ${rel}?`,
72
+ initialValue: false,
73
+ }),
74
+ );
75
+ if (!confirmed) {
76
+ humanLog('Aborted. Re-run with --overwrite to replace the file.');
77
+ return;
78
+ }
59
79
  }
60
80
  }
61
81
 
@@ -100,10 +120,10 @@ export function registerTemplate(program) {
100
120
  for (const t of blocks) renderEntry(t);
101
121
  }
102
122
  humanLog('\nUsage:');
103
- humanLog(` ${run} template <id> [target-path] Scaffold page or block`);
104
- humanLog(` ${run} template <id> --skeleton Layout reference`);
105
- humanLog(` ${run} template --list --type block List only blocks`);
106
- humanLog(` ${run} template --list --package <pkg> List from one package\n`);
123
+ humanLog(' astryx template <id> [target-path] Scaffold page or block');
124
+ humanLog(' astryx template <id> --skeleton Layout reference');
125
+ humanLog(' astryx template --list --type block List only blocks');
126
+ humanLog(' astryx template --list --package <pkg> List from one package\n');
107
127
  break;
108
128
  }
109
129
 
@@ -37,7 +37,7 @@ import * as fs from 'node:fs';
37
37
  import * as path from 'node:path';
38
38
  import {execFile} from 'node:child_process';
39
39
  import {promisify} from 'node:util';
40
- import * as p from '../lib/term-log.mjs';
40
+ import * as p from '@clack/prompts';
41
41
  import {ensureJscodeshift} from '../codemods/ensure-jscodeshift.mjs';
42
42
  import {getTransformsBetween, latestVersion} from '../codemods/registry.mjs';
43
43
  import {runCodemods} from '../codemods/runner.mjs';
@@ -47,7 +47,7 @@ import {
47
47
  } from '../codemods/integration-discovery.mjs';
48
48
  import {runIntegrationCodemods} from '../codemods/integration-runner.mjs';
49
49
  import {installAgentDocs, discoverAgentDocs} from './agent-docs.mjs';
50
- import {getCliInvocation, formatCliCommand} from '../utils/package-manager.mjs';
50
+ import {getRunPrefix} from '../utils/package-manager.mjs';
51
51
  import {isValidSemver, semverGte} from '../utils/semver.mjs';
52
52
  import {jsonOut, jsonError} from '../lib/json.mjs';
53
53
  import {Project} from '../lib/project.mjs';
@@ -176,7 +176,7 @@ export function registerUpgrade(program) {
176
176
 
177
177
  if (!options.list && !options.from) {
178
178
  const msg =
179
- `Missing required --from. Install the target version first, then run \`${getCliInvocation()} upgrade --from <old-version>\`.`;
179
+ 'Missing required --from. Install the target version first, then run `astryx upgrade --from <old-version>`.';
180
180
  if (json)
181
181
  return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
182
182
  p.log.error(msg);
@@ -237,7 +237,7 @@ export function registerUpgrade(program) {
237
237
  const installed = detectInstalledTargetVersion();
238
238
  if (!installed) {
239
239
  const msg =
240
- `Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun \`${getCliInvocation()} upgrade --from <old-version>\`.`;
240
+ 'Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun `astryx upgrade --from <old-version>`.';
241
241
  if (json)
242
242
  return jsonError(msg, undefined, ERROR_CODES.ERR_VERSION_DETECT);
243
243
  p.log.error(msg);
@@ -390,9 +390,6 @@ export function registerUpgrade(program) {
390
390
  const codemodFlags = coreConfigCodemodNames
391
391
  .map(name => `--codemod ${name}`)
392
392
  .join(' ');
393
- // Canonical (bare) form — this is a structured, machine-executable
394
- // field in the --json envelope. The human print below is made
395
- // install-aware via formatCliCommand.
396
393
  const suggestedCommand = `astryx upgrade --from ${currentVersion} ${codemodFlags} --apply`;
397
394
  const guidance =
398
395
  'Your astryx.config currently fails strict validation, but a pending ' +
@@ -412,7 +409,7 @@ export function registerUpgrade(program) {
412
409
  });
413
410
  }
414
411
  p.log.warn(guidance);
415
- p.log.info(` ${formatCliCommand(suggestedCommand)}`);
412
+ p.log.info(` ${suggestedCommand}`);
416
413
  p.log.info(
417
414
  'Integrations are skipped in this preview; they will be processed on the --apply run.',
418
415
  );
@@ -622,7 +619,7 @@ export function registerUpgrade(program) {
622
619
  } catch {
623
620
  if (!json) {
624
621
  p.log.warn(
625
- `Could not update agent docs. Run \`${getCliInvocation()} init --features agents\` to update manually.`,
622
+ `Could not update agent docs. Run \`${getRunPrefix()} astryx init --features agents\` to update manually.`,
626
623
  );
627
624
  }
628
625
  }
@@ -24,7 +24,7 @@ beforeEach(() => {
24
24
  logCalls.push(args.join(' '));
25
25
  });
26
26
  vi.spyOn(console, 'error').mockImplementation(() => {});
27
- // Some human logs are written straight to process.stdout — capture that too.
27
+ // @clack/prompts writes directly to process.stdout — capture that too.
28
28
  vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
29
29
  stdoutCalls.push(typeof chunk === 'string' ? chunk : chunk.toString());
30
30
  return true;
package/src/index.mjs CHANGED
@@ -12,14 +12,13 @@ import {fileURLToPath} from 'node:url';
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
14
  import {checkForUpdate} from './utils/update-check.mjs';
15
- import {getCliInvocation} from './utils/package-manager.mjs';
15
+ import {getRunPrefix} from './utils/package-manager.mjs';
16
16
  import {API_VERSION, setJsonMode} from './lib/json.mjs';
17
17
  import {buildManifest} from './lib/manifest.mjs';
18
18
  import {cliError} from './lib/cli-error.mjs';
19
19
  import {ERROR_CODES} from './lib/error-codes.mjs';
20
20
  import {levenshteinDistance} from './lib/string-utils.mjs';
21
21
  import {installJsonShim} from './lib/json-shim.mjs';
22
- import {isAstryxInitialized} from './commands/agent-docs.mjs';
23
22
 
24
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
24
 
@@ -175,7 +174,7 @@ function fullCommandName(actionCommand) {
175
174
  *
176
175
  * If --json is set on a command that is not on the JSON_SUPPORTED allowlist,
177
176
  * emit a structured error envelope and exit 1 — without running the command's
178
- * action (so no filesystem mutations, no interactive prompts, no spawned processes).
177
+ * action (so no filesystem mutations, no clack prompts, no spawned processes).
179
178
  *
180
179
  * This is the single source of truth for "command does not support --json".
181
180
  * Individual commands should NOT re-check this; they may assume that if their
@@ -238,40 +237,6 @@ program.hook('postAction', (thisCommand, actionCommand) => {
238
237
  }
239
238
  });
240
239
 
241
- /**
242
- * Enforcement layer 3 — setup nudge. If this project hasn't run `astryx init`
243
- * yet (no Astryx marker in any agent-doc file — see isAstryxInitialized), remind
244
- * the user/agent that setup is missing.
245
- *
246
- * Uses `preAction` (not postAction) so it fires for EVERY valid command — even
247
- * ones whose action errors or calls process.exit (postAction is skipped then).
248
- *
249
- * Suppressed in --json: that is machine output with a strict clean stdout+stderr
250
- * contract (json-shim.test: "error envelopes have empty stderr"), and --json
251
- * consumers parse stdout, not stderr — a stderr nudge would not reach them anyway.
252
- * The core/cli postinstall layers already nudge at install time regardless of
253
- * --json; a machine-readable nudge could later be an envelope field. Also skipped
254
- * for the installer commands themselves and outside a project (no package.json).
255
- */
256
- const SETUP_NUDGE_EXEMPT = new Set(['init', 'agent-docs']);
257
- program.hook('preAction', (thisCommand, actionCommand) => {
258
- try {
259
- if (program.opts().json) return; // machine mode — keep --json output clean
260
- if (SETUP_NUDGE_EXEMPT.has(actionCommand.name())) return;
261
- const cwd = process.cwd();
262
- if (!fs.existsSync(path.join(cwd, 'package.json'))) return; // not a project
263
- if (isAstryxInitialized(cwd)) return; // already set up — stay quiet
264
- // Same wording as the core/cli postinstall nudges. #4151's getCliInvocation()
265
- // renders the correct form for THIS project — scoped `npx @astryxdesign/cli`
266
- // one-off, or `<pm> astryx` when installed — never the bare `npx astryx` footgun.
267
- console.error(
268
- `\nNext step: run \`${getCliInvocation(cwd)} init\` to finish setup and install the Astryx agent prompt.`,
269
- );
270
- } catch {
271
- // Never let the nudge break a command.
272
- }
273
- });
274
-
275
240
  /**
276
241
  * Command registry — each command is lazy-loaded so a broken command
277
242
  * doesn't take down the entire CLI.
@@ -340,14 +305,15 @@ program
340
305
  console.log(` ${c.name}${tag}`);
341
306
  if (c.description) console.log(` ${c.description}`);
342
307
  }
343
- console.log(`\nRun \`${getCliInvocation()} manifest --json\` for the full structured manifest.\n`);
308
+ console.log(`\nRun \`astryx manifest --json\` for the full structured manifest.\n`);
344
309
  });
345
310
 
346
311
  // Hidden command used by package.json postinstall scripts
347
312
  program
348
313
  .command('postinstall', {hidden: true})
349
314
  .action(() => {
350
- const r = getCliInvocation();
315
+ const run = getRunPrefix();
316
+ const r = `${run} xds`;
351
317
  const pad = (s, len) => s + ' '.repeat(Math.max(0, len - s.length));
352
318
  const W = 49; // inner width of the box
353
319
  const line = (s) => ` │ ${pad(s, W)}│`;
@@ -357,7 +323,7 @@ ${line('')}
357
323
  ${line(' Design system installed!')}
358
324
  ${line('')}
359
325
  ${line(' Get started:')}
360
- ${line(` ${r} init Setup + AI agent docs`)}
326
+ ${line(` ${r} init Interactive setup`)}
361
327
  ${line(` ${r} --help See all commands`)}
362
328
  ${line('')}
363
329
  ${line(' Or run directly:')}
@@ -6,7 +6,6 @@
6
6
 
7
7
  import {discoverComponents, findComponentReadme, resolveImportPath} from './component-discovery.mjs';
8
8
  import {loadDocs} from './component-loader.mjs';
9
- import {getCliInvocation} from '../utils/package-manager.mjs';
10
9
 
11
10
  /**
12
11
  * Derive the `defineTheme` component-override key from a theming target.
@@ -82,7 +81,7 @@ function formatSubComponent(comp) {
82
81
  if (table) {
83
82
  out.push(table + '\n');
84
83
  } else {
85
- out.push(`See \`${getCliInvocation()} component ${comp.name}\` for props and usage.\n`);
84
+ out.push(`See \`astryx component ${comp.name}\` for props and usage.\n`);
86
85
  }
87
86
  return out;
88
87
  }
@@ -0,0 +1,76 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Interactivity contract for the CLI.
5
+ *
6
+ * A single source of truth for "can this process prompt the user?". Several
7
+ * commands launch @clack/prompts wizards; in a non-interactive context (CI,
8
+ * piped stdin/stdout, no TTY) those prompts block forever. Historically each
9
+ * command answered this question differently — some checked `stdout.isTTY`,
10
+ * one checked `stdin.isTTY`, one added `!process.env.CI`, and some had no
11
+ * guard at all. This module centralizes the check so every command behaves
12
+ * identically.
13
+ *
14
+ * Two entry points, for two situations:
15
+ *
16
+ * - `requireInteractive()` — for commands whose prompt IS the work
17
+ * (e.g. `astryx init`, `astryx theme`). With no TTY there is nothing to do, so
18
+ * fail fast (exit 1) with actionable, non-interactive guidance.
19
+ *
20
+ * - `isInteractive()` — for commands with an OPTIONAL secondary prompt
21
+ * that runs after the primary work has already succeeded; callers use
22
+ * this to skip the prompt gracefully in non-interactive contexts.
23
+ */
24
+
25
+ /**
26
+ * True when the process can safely run an interactive prompt.
27
+ *
28
+ * Requires BOTH stdin and stdout to be TTYs (clack reads stdin and renders to
29
+ * stdout) and that we are not in a CI environment. A CI runner may allocate a
30
+ * pseudo-TTY, so `process.env.CI` is an explicit override: never prompt in CI.
31
+ *
32
+ * @param {object} [env] - Override hook for tests.
33
+ * @param {boolean} [env.stdinTTY=process.stdin.isTTY]
34
+ * @param {boolean} [env.stdoutTTY=process.stdout.isTTY]
35
+ * @param {boolean} [env.ci=Boolean(process.env.CI)]
36
+ * @returns {boolean}
37
+ */
38
+ export function isInteractive({
39
+ stdinTTY = Boolean(process.stdin && process.stdin.isTTY),
40
+ stdoutTTY = Boolean(process.stdout && process.stdout.isTTY),
41
+ ci = Boolean(process.env.CI),
42
+ } = {}) {
43
+ if (ci) return false;
44
+ return stdinTTY && stdoutTTY;
45
+ }
46
+
47
+ /**
48
+ * Guard for commands whose primary action is an interactive wizard. When the
49
+ * process is non-interactive, prints an actionable error and exits 1 instead
50
+ * of hanging on a prompt that will never receive input.
51
+ *
52
+ * @param {object} options
53
+ * @param {string} options.command - Command name for the message, e.g. 'init'.
54
+ * @param {string} options.hint - Concrete non-interactive invocation, e.g.
55
+ * '`pnpm astryx init --all` or `--features agents,theme,template`'.
56
+ * @param {boolean} [options.json=false] - When true, the command does not
57
+ * support --json; we still exit 1 but skip the human-formatted guidance.
58
+ * @param {object} [env] - Forwarded to isInteractive (test hook).
59
+ * @returns {void} Returns when interactive; otherwise calls process.exit(1).
60
+ */
61
+ export function requireInteractive({command, hint, json = false} = {}, env) {
62
+ if (isInteractive(env)) return;
63
+ const name = command ? `astryx ${command}` : 'this command';
64
+ console.error(
65
+ `Error: \`${name}\` with no flags is interactive and requires a TTY.`,
66
+ );
67
+ if (hint) {
68
+ console.error(`Run non-interactively with: ${hint}`);
69
+ }
70
+ if (!json) {
71
+ console.error(
72
+ 'Detected a non-interactive environment (no TTY, piped I/O, or CI=1).',
73
+ );
74
+ }
75
+ process.exit(1);
76
+ }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Unit tests for the interactivity contract.
5
+ *
6
+ * isInteractive() is pure given its injected env, so these are fast unit
7
+ * tests. The "does the command actually fail fast instead of hanging" proof
8
+ * lives in the per-command subprocess tests (init/theme non-interactive).
9
+ */
10
+
11
+ import {describe, it, expect, vi, afterEach} from 'vitest';
12
+ import {isInteractive, requireInteractive} from './interactive.mjs';
13
+
14
+ describe('isInteractive', () => {
15
+ it('is true only when stdin AND stdout are TTYs and not CI', () => {
16
+ expect(isInteractive({stdinTTY: true, stdoutTTY: true, ci: false})).toBe(true);
17
+ });
18
+
19
+ it('is false when stdin is not a TTY (piped input)', () => {
20
+ expect(isInteractive({stdinTTY: false, stdoutTTY: true, ci: false})).toBe(false);
21
+ });
22
+
23
+ it('is false when stdout is not a TTY (piped output)', () => {
24
+ expect(isInteractive({stdinTTY: true, stdoutTTY: false, ci: false})).toBe(false);
25
+ });
26
+
27
+ it('is false in CI even with a pseudo-TTY on both streams', () => {
28
+ expect(isInteractive({stdinTTY: true, stdoutTTY: true, ci: true})).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('requireInteractive', () => {
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ it('returns (does not exit) when interactive', () => {
38
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
39
+ throw new Error('exit should not be called');
40
+ });
41
+ expect(() =>
42
+ requireInteractive(
43
+ {command: 'init', hint: '`astryx init --all`'},
44
+ {stdinTTY: true, stdoutTTY: true, ci: false},
45
+ ),
46
+ ).not.toThrow();
47
+ expect(exit).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('exits 1 with actionable guidance when non-interactive', () => {
51
+ const exit = vi
52
+ .spyOn(process, 'exit')
53
+ .mockImplementation(() => {
54
+ throw new Error('__exit__');
55
+ });
56
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ expect(() =>
58
+ requireInteractive(
59
+ {command: 'theme', hint: '`astryx theme <preset>`'},
60
+ {stdinTTY: false, stdoutTTY: false, ci: false},
61
+ ),
62
+ ).toThrow('__exit__');
63
+ expect(exit).toHaveBeenCalledWith(1);
64
+ const output = err.mock.calls.map(c => c.join(' ')).join('\n');
65
+ expect(output).toMatch(/requires a TTY/i);
66
+ expect(output).toMatch(/astryx theme <preset>/);
67
+ expect(output).toMatch(/`astryx theme`/);
68
+ expect(output).not.toMatch(/\bxds\b/);
69
+ });
70
+ });
@@ -72,81 +72,3 @@ export function getRunPrefix(targetDir) {
72
72
  default: return 'npx';
73
73
  }
74
74
  }
75
-
76
- /** The published CLI package name — used for one-off (uninstalled) invocations. */
77
- export const CLI_PACKAGE = '@astryxdesign/cli';
78
-
79
- /** The CLI binary name — only resolves once the CLI is installed (or run via CLI_PACKAGE). */
80
- export const CLI_BIN = 'astryx';
81
-
82
- /**
83
- * Get the one-off ("dlx") runner for the detected package manager.
84
- *
85
- * Unlike {@link getRunPrefix} (which runs an *installed* binary), this fetches
86
- * and runs a package on demand — so it is always paired with the scoped
87
- * {@link CLI_PACKAGE}, never the bare `astryx` bin. Running bare `npx astryx`
88
- * without the CLI installed resolves to an unrelated package on the registry.
89
- *
90
- * @param {string} [targetDir]
91
- * @returns {string} e.g. 'npx', 'pnpm dlx', 'yarn dlx', 'bunx'
92
- */
93
- export function getDlxPrefix(targetDir) {
94
- const pm = detectPackageManager(targetDir);
95
- switch (pm) {
96
- case 'yarn': return 'yarn dlx';
97
- case 'pnpm': return 'pnpm dlx';
98
- case 'bun': return 'bunx';
99
- case 'npm':
100
- default: return 'npx';
101
- }
102
- }
103
-
104
- /**
105
- * Heuristic: was the running CLI launched one-off via a package runner
106
- * (npx / pnpm dlx / yarn dlx / bunx) rather than from an installed dependency?
107
- *
108
- * We sniff the entry path (`process.argv[1]`) for well-known runner-cache
109
- * markers. This errs safe in both directions: a false negative falls back to
110
- * the installed form (`<prefix> astryx`, the historical behavior), and a false
111
- * positive emits the always-valid scoped form (`<dlx> @astryxdesign/cli`).
112
- *
113
- * @returns {boolean}
114
- */
115
- export function isCliOneOff() {
116
- const entry = String(process.argv[1] || '').replace(/\\/g, '/');
117
- return /\/_npx\/|\/dlx[-/]|\/\.bun\/install\/cache\/|\/bunx-/.test(entry);
118
- }
119
-
120
- /**
121
- * The safe, install-aware CLI invocation stem to suggest to users.
122
- *
123
- * - Installed / global / dev: `<run-prefix> astryx` (e.g. `pnpm exec astryx`).
124
- * Bare `astryx` resolves to the local (or global) binary.
125
- * - One-off (npx/dlx cache): `<dlx-prefix> @astryxdesign/cli` — the bare
126
- * `astryx` name isn't on disk, so npm would fetch an unrelated registry
127
- * package; the scoped package always resolves to us.
128
- *
129
- * @param {string} [targetDir]
130
- * @returns {string}
131
- */
132
- export function getCliInvocation(targetDir) {
133
- if (isCliOneOff()) return `${getDlxPrefix(targetDir)} ${CLI_PACKAGE}`;
134
- return `${getRunPrefix(targetDir)} ${CLI_BIN}`;
135
- }
136
-
137
- /**
138
- * Format a full, runnable CLI command from a subcommand string.
139
- *
140
- * Accepts either `astryx component Button` or `component Button` (a leading
141
- * `astryx` token is stripped) and prepends the install-aware invocation stem
142
- * from {@link getCliInvocation}.
143
- *
144
- * @param {string} command e.g. 'astryx component Button' | 'docs tokens'
145
- * @param {string} [targetDir]
146
- * @returns {string}
147
- */
148
- export function formatCliCommand(command, targetDir) {
149
- const sub = String(command).replace(/^\s*astryx\b\s*/, '').trim();
150
- const stem = getCliInvocation(targetDir);
151
- return sub ? `${stem} ${sub}` : stem;
152
- }