@astryxdesign/cli 0.1.6-canary.ff5dfca → 0.1.7-canary.04cd8f7

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 (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +115 -19
  3. package/docs/cli-integrations.doc.mjs +150 -0
  4. package/docs/getting-started.doc.mjs +9 -9
  5. package/docs/migration.doc.mjs +18 -18
  6. package/docs/principles.doc.dense.mjs +1 -1
  7. package/docs/principles.doc.mjs +6 -6
  8. package/docs/principles.doc.zh.mjs +1 -1
  9. package/docs/styling-libraries.doc.mjs +3 -3
  10. package/docs/styling.doc.mjs +4 -4
  11. package/docs/theme.doc.dense.mjs +2 -2
  12. package/docs/theme.doc.mjs +7 -7
  13. package/docs/theme.doc.zh.mjs +1 -1
  14. package/docs/tokens.doc.mjs +1 -1
  15. package/docs/working-with-ai.doc.mjs +18 -18
  16. package/package.json +9 -10
  17. package/src/api/doctor.mjs +3 -3
  18. package/src/codemods/ensure-jscodeshift.mjs +11 -27
  19. package/src/codemods/run-codemod.mjs +1 -1
  20. package/src/codemods/runner.mjs +2 -2
  21. package/src/commands/agent-docs.mjs +7 -8
  22. package/src/commands/agent-docs.test.mjs +11 -4
  23. package/src/commands/build-theme.mjs +10 -71
  24. package/src/commands/build.mjs +15 -15
  25. package/src/commands/component/index.mjs +4 -4
  26. package/src/commands/discover.mjs +7 -5
  27. package/src/commands/docs.mjs +4 -4
  28. package/src/commands/hook/index.mjs +4 -4
  29. package/src/commands/init.mjs +48 -152
  30. package/src/commands/init.next-steps.test.mjs +1 -1
  31. package/src/commands/interactive-guard.test.mjs +19 -22
  32. package/src/commands/json-contract.test.mjs +1 -1
  33. package/src/commands/layout.mjs +1 -1
  34. package/src/commands/search.mjs +4 -4
  35. package/src/commands/swizzle.mjs +11 -34
  36. package/src/commands/template.mjs +11 -31
  37. package/src/commands/upgrade.mjs +9 -6
  38. package/src/commands/upgrade.test.mjs +1 -1
  39. package/src/index.mjs +5 -6
  40. package/src/lib/component-format.mjs +2 -1
  41. package/src/lib/term-log.mjs +48 -0
  42. package/src/utils/package-manager.mjs +78 -0
  43. package/src/utils/package-manager.test.mjs +108 -1
  44. package/src/utils/path-safety.mjs +0 -18
  45. package/src/utils/update-check.mjs +2 -1
  46. package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
  47. package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +2 -7
  48. package/docs/integration-authoring.md +0 -105
  49. package/src/utils/interactive.mjs +0 -76
  50. package/src/utils/interactive.test.mjs +0 -70
@@ -15,18 +15,16 @@
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';
19
18
  import {findCoreDir, listComponents} from '../utils/paths.mjs';
20
19
  import {
21
20
  assertWithin,
22
21
  PathSafetyError,
23
- isNonInteractive,
24
22
  } from '../utils/path-safety.mjs';
25
23
  import {jsonOut, humanLog} from '../lib/json.mjs';
26
24
  import {cliError} from '../lib/cli-error.mjs';
27
25
  import {ERROR_CODES} from '../lib/error-codes.mjs';
28
26
  import {checkGhCli} from '../utils/github.mjs';
29
- import {getRunPrefix} from '../utils/package-manager.mjs';
27
+ import {getCliInvocation} from '../utils/package-manager.mjs';
30
28
  import {Project} from '../lib/project.mjs';
31
29
  import {
32
30
  CORE_PACKAGE,
@@ -99,14 +97,6 @@ function buildFeedback(component, issuesUrl) {
99
97
  return feedback;
100
98
  }
101
99
 
102
- function isCancel(value) {
103
- if (p.isCancel(value)) {
104
- p.cancel('Cancelled.');
105
- process.exit(0);
106
- }
107
- return value;
108
- }
109
-
110
100
  /**
111
101
  * Load the configured integrations + core issues URL for `cwd`, swallowing any
112
102
  * config errors so swizzle never hard-fails on a malformed/absent config. An
@@ -190,6 +180,7 @@ export function registerSwizzle(program) {
190
180
  .action(async (component, options) => {
191
181
  const coreDir = findCoreDir(process.cwd());
192
182
  const json = program.opts().json || false;
183
+ const run = getCliInvocation();
193
184
 
194
185
  if (!coreDir) {
195
186
  cliError(
@@ -207,10 +198,10 @@ export function registerSwizzle(program) {
207
198
  for (const name of components) {
208
199
  humanLog(` ${name}`);
209
200
  }
210
- humanLog(`\nUsage: astryx swizzle <component>\n`);
211
- humanLog('Example: astryx swizzle Button');
201
+ humanLog(`\nUsage: ${run} swizzle <component>\n`);
202
+ humanLog(`Example: ${run} swizzle Button`);
212
203
  humanLog(
213
- ' astryx swizzle XDSButton (XDS prefix also works)\n',
204
+ ` ${run} swizzle XDSButton (XDS prefix also works)\n`,
214
205
  );
215
206
  return;
216
207
  }
@@ -314,25 +305,11 @@ export function registerSwizzle(program) {
314
305
 
315
306
  if (existingFiles.length > 0 && !options.overwrite) {
316
307
  const relOutputForMsg = path.relative(process.cwd(), outputDir) || '.';
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
- }
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;
336
313
  }
337
314
 
338
315
  fs.mkdirSync(outputDir, {recursive: true});
@@ -411,7 +388,7 @@ export function registerSwizzle(program) {
411
388
  humanLog(
412
389
  ' Without one they render unstyled (no error). See setup per framework:',
413
390
  );
414
- humanLog(` ${getRunPrefix()} astryx docs styling`);
391
+ humanLog(` ${run} docs styling`);
415
392
  humanLog(
416
393
  ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font —',
417
394
  );
@@ -6,25 +6,16 @@
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';
11
9
  import {jsonOut, humanLog} from '../lib/json.mjs';
12
10
  import {cliError} from '../lib/cli-error.mjs';
13
11
  import {ERROR_CODES} from '../lib/error-codes.mjs';
14
12
  import {template as templateApi} from '../api/template.mjs';
15
13
  import {Project} from '../lib/project.mjs';
16
14
  import {warnOnIntegrationIssues} from '../lib/integration-warnings.mjs';
15
+ import {getCliInvocation} from '../utils/package-manager.mjs';
17
16
 
18
17
  export {discoverTemplates, listTemplates} from '../api/template.mjs';
19
18
 
20
- function isCancel(value) {
21
- if (p.isCancel(value)) {
22
- p.cancel('Cancelled.');
23
- process.exit(0);
24
- }
25
- return value;
26
- }
27
-
28
19
  export function registerTemplate(program) {
29
20
  program
30
21
  .command('template [name] [path]')
@@ -36,6 +27,7 @@ export function registerTemplate(program) {
36
27
  .option('-f, --overwrite', 'Overwrite existing files without prompting')
37
28
  .action(async (name, targetPath, options) => {
38
29
  const json = program.opts().json || false;
30
+ const run = getCliInvocation();
39
31
 
40
32
  // Non-blocking nudge: if any configured integration has validation
41
33
  // issues, print one compact line to stderr pointing at
@@ -59,23 +51,11 @@ export function registerTemplate(program) {
59
51
  const collision = await detectTemplateCollision(name, targetPath);
60
52
  if (collision && !options.overwrite) {
61
53
  const rel = path.relative(process.cwd(), collision) || collision;
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
- }
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;
79
59
  }
80
60
  }
81
61
 
@@ -120,10 +100,10 @@ export function registerTemplate(program) {
120
100
  for (const t of blocks) renderEntry(t);
121
101
  }
122
102
  humanLog('\nUsage:');
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');
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`);
127
107
  break;
128
108
  }
129
109
 
@@ -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 '@clack/prompts';
40
+ import * as p from '../lib/term-log.mjs';
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 {getRunPrefix} from '../utils/package-manager.mjs';
50
+ import {getCliInvocation, formatCliCommand} 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 `astryx upgrade --from <old-version>`.';
179
+ `Missing required --from. Install the target version first, then run \`${getCliInvocation()} 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 `astryx upgrade --from <old-version>`.';
240
+ `Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun \`${getCliInvocation()} 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,6 +390,9 @@ 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.
393
396
  const suggestedCommand = `astryx upgrade --from ${currentVersion} ${codemodFlags} --apply`;
394
397
  const guidance =
395
398
  'Your astryx.config currently fails strict validation, but a pending ' +
@@ -409,7 +412,7 @@ export function registerUpgrade(program) {
409
412
  });
410
413
  }
411
414
  p.log.warn(guidance);
412
- p.log.info(` ${suggestedCommand}`);
415
+ p.log.info(` ${formatCliCommand(suggestedCommand)}`);
413
416
  p.log.info(
414
417
  'Integrations are skipped in this preview; they will be processed on the --apply run.',
415
418
  );
@@ -619,7 +622,7 @@ export function registerUpgrade(program) {
619
622
  } catch {
620
623
  if (!json) {
621
624
  p.log.warn(
622
- `Could not update agent docs. Run \`${getRunPrefix()} astryx init --features agents\` to update manually.`,
625
+ `Could not update agent docs. Run \`${getCliInvocation()} init --features agents\` to update manually.`,
623
626
  );
624
627
  }
625
628
  }
@@ -24,7 +24,7 @@ beforeEach(() => {
24
24
  logCalls.push(args.join(' '));
25
25
  });
26
26
  vi.spyOn(console, 'error').mockImplementation(() => {});
27
- // @clack/prompts writes directly to process.stdout — capture that too.
27
+ // Some human logs are written straight 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,7 +12,7 @@ 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 {getRunPrefix} from './utils/package-manager.mjs';
15
+ import {getCliInvocation} 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';
@@ -174,7 +174,7 @@ function fullCommandName(actionCommand) {
174
174
  *
175
175
  * If --json is set on a command that is not on the JSON_SUPPORTED allowlist,
176
176
  * emit a structured error envelope and exit 1 — without running the command's
177
- * action (so no filesystem mutations, no clack prompts, no spawned processes).
177
+ * action (so no filesystem mutations, no interactive prompts, no spawned processes).
178
178
  *
179
179
  * This is the single source of truth for "command does not support --json".
180
180
  * Individual commands should NOT re-check this; they may assume that if their
@@ -305,15 +305,14 @@ program
305
305
  console.log(` ${c.name}${tag}`);
306
306
  if (c.description) console.log(` ${c.description}`);
307
307
  }
308
- console.log(`\nRun \`astryx manifest --json\` for the full structured manifest.\n`);
308
+ console.log(`\nRun \`${getCliInvocation()} manifest --json\` for the full structured manifest.\n`);
309
309
  });
310
310
 
311
311
  // Hidden command used by package.json postinstall scripts
312
312
  program
313
313
  .command('postinstall', {hidden: true})
314
314
  .action(() => {
315
- const run = getRunPrefix();
316
- const r = `${run} xds`;
315
+ const r = getCliInvocation();
317
316
  const pad = (s, len) => s + ' '.repeat(Math.max(0, len - s.length));
318
317
  const W = 49; // inner width of the box
319
318
  const line = (s) => ` │ ${pad(s, W)}│`;
@@ -323,7 +322,7 @@ ${line('')}
323
322
  ${line(' Design system installed!')}
324
323
  ${line('')}
325
324
  ${line(' Get started:')}
326
- ${line(` ${r} init Interactive setup`)}
325
+ ${line(` ${r} init Setup + AI agent docs`)}
327
326
  ${line(` ${r} --help See all commands`)}
328
327
  ${line('')}
329
328
  ${line(' Or run directly:')}
@@ -6,6 +6,7 @@
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';
9
10
 
10
11
  /**
11
12
  * Derive the `defineTheme` component-override key from a theming target.
@@ -81,7 +82,7 @@ function formatSubComponent(comp) {
81
82
  if (table) {
82
83
  out.push(table + '\n');
83
84
  } else {
84
- out.push(`See \`astryx component ${comp.name}\` for props and usage.\n`);
85
+ out.push(`See \`${getCliInvocation()} component ${comp.name}\` for props and usage.\n`);
85
86
  }
86
87
  return out;
87
88
  }
@@ -0,0 +1,48 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Minimal non-interactive terminal logger.
5
+ *
6
+ * @input message strings from CLI commands/codemods
7
+ * @output plain lines on stdout via humanLog (suppressed in --json mode)
8
+ * @position src/lib — shared output helper, no side effects on import
9
+ *
10
+ * The CLI is fully non-interactive: it never prompts, so it only needs plain,
11
+ * unbuffered output. This provides the *output-only* surface (`log.*`, `intro`,
12
+ * `outro`) the CLI needs, so it has no dependency on any prompt library.
13
+ *
14
+ * All output is routed through `humanLog`, the CLI's stdout-discipline
15
+ * primitive, which is a no-op in `--json` mode — so these human logs can never
16
+ * corrupt a JSON envelope.
17
+ *
18
+ * Call sites use it as `import * as p from '../lib/term-log.mjs'` and call
19
+ * `p.log.info(...)`, `p.intro(...)`, `p.outro(...)`.
20
+ */
21
+
22
+ import {humanLog} from './json.mjs';
23
+
24
+ const toStr = (msg) => (msg === undefined || msg === null ? '' : String(msg));
25
+
26
+ /**
27
+ * Human-facing log surface (the small `log` API the CLI uses). All lines go to
28
+ * stdout via humanLog; the level prefixes are cosmetic. `--json` mode suppresses
29
+ * every one of these, keeping machine-readable stdout clean.
30
+ */
31
+ export const log = {
32
+ message: (msg) => humanLog(toStr(msg)),
33
+ info: (msg) => humanLog(toStr(msg)),
34
+ step: (msg) => humanLog(toStr(msg)),
35
+ success: (msg) => humanLog(`✓ ${toStr(msg)}`),
36
+ warn: (msg) => humanLog(`⚠ ${toStr(msg)}`),
37
+ error: (msg) => humanLog(`✗ ${toStr(msg)}`),
38
+ };
39
+
40
+ /** Banner printed at the start of a multi-step command. */
41
+ export function intro(title) {
42
+ humanLog(`\n${toStr(title)}`);
43
+ }
44
+
45
+ /** Footer printed at the end of a multi-step command. */
46
+ export function outro(message) {
47
+ humanLog(`${toStr(message)}\n`);
48
+ }
@@ -72,3 +72,81 @@ 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
+ }
@@ -4,9 +4,16 @@ import {describe, it, expect, afterEach, vi} from 'vitest';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
- import {detectPackageManager} from './package-manager.mjs';
7
+ import {
8
+ detectPackageManager,
9
+ getDlxPrefix,
10
+ isCliOneOff,
11
+ getCliInvocation,
12
+ formatCliCommand,
13
+ } from './package-manager.mjs';
8
14
 
9
15
  let tmpDir;
16
+ const ORIGINAL_ARGV1 = process.argv[1];
10
17
 
11
18
  afterEach(() => {
12
19
  if (tmpDir) {
@@ -15,6 +22,7 @@ afterEach(() => {
15
22
  }
16
23
  vi.restoreAllMocks();
17
24
  delete process.env.npm_config_user_agent;
25
+ process.argv[1] = ORIGINAL_ARGV1;
18
26
  });
19
27
 
20
28
  function makeTmpDir() {
@@ -111,3 +119,102 @@ describe('detectPackageManager', () => {
111
119
  expect(detectPackageManager(dir)).toBe('bun');
112
120
  });
113
121
  });
122
+
123
+ describe('getDlxPrefix', () => {
124
+ it('returns "pnpm dlx" for pnpm projects', () => {
125
+ const dir = makeTmpDir();
126
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
127
+ expect(getDlxPrefix(dir)).toBe('pnpm dlx');
128
+ });
129
+
130
+ it('returns "yarn dlx" for yarn projects', () => {
131
+ const dir = makeTmpDir();
132
+ fs.writeFileSync(path.join(dir, 'yarn.lock'), '');
133
+ expect(getDlxPrefix(dir)).toBe('yarn dlx');
134
+ });
135
+
136
+ it('returns "bunx" for bun projects', () => {
137
+ const dir = makeTmpDir();
138
+ fs.writeFileSync(path.join(dir, 'bun.lockb'), '');
139
+ expect(getDlxPrefix(dir)).toBe('bunx');
140
+ });
141
+
142
+ it('falls back to "npx" with no signals', () => {
143
+ const dir = makeTmpDir();
144
+ delete process.env.npm_config_user_agent;
145
+ expect(getDlxPrefix(dir)).toBe('npx');
146
+ });
147
+ });
148
+
149
+ describe('isCliOneOff', () => {
150
+ it('detects an npm npx cache entry', () => {
151
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
152
+ expect(isCliOneOff()).toBe(true);
153
+ });
154
+
155
+ it('detects a pnpm dlx cache entry', () => {
156
+ process.argv[1] = '/home/u/.cache/pnpm/dlx/9f/node_modules/@astryxdesign/cli/bin/astryx.mjs';
157
+ expect(isCliOneOff()).toBe(true);
158
+ });
159
+
160
+ it('detects a bunx cache entry', () => {
161
+ process.argv[1] = '/home/u/.bun/install/cache/@astryxdesign/cli/bin/astryx.mjs';
162
+ expect(isCliOneOff()).toBe(true);
163
+ });
164
+
165
+ it('is false for an installed node_modules entry', () => {
166
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
167
+ expect(isCliOneOff()).toBe(false);
168
+ });
169
+
170
+ it('is false for a source checkout (dev) entry', () => {
171
+ process.argv[1] = '/repo/packages/cli/bin/astryx.mjs';
172
+ expect(isCliOneOff()).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('getCliInvocation', () => {
177
+ it('uses the run-prefix + bare bin when installed (not one-off)', () => {
178
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
179
+ const dir = makeTmpDir();
180
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
181
+ expect(getCliInvocation(dir)).toBe('pnpm exec astryx');
182
+ });
183
+
184
+ it('uses the dlx runner + scoped package when run one-off', () => {
185
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
186
+ const dir = makeTmpDir();
187
+ delete process.env.npm_config_user_agent;
188
+ expect(getCliInvocation(dir)).toBe('npx @astryxdesign/cli');
189
+ });
190
+
191
+ it('pairs the dlx runner with the scoped package for pnpm one-off', () => {
192
+ process.argv[1] = '/home/u/.cache/pnpm/dlx/9f/node_modules/@astryxdesign/cli/bin/astryx.mjs';
193
+ const dir = makeTmpDir();
194
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
195
+ expect(getCliInvocation(dir)).toBe('pnpm dlx @astryxdesign/cli');
196
+ });
197
+ });
198
+
199
+ describe('formatCliCommand', () => {
200
+ it('strips a leading "astryx" token and prepends the invocation stem', () => {
201
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
202
+ const dir = makeTmpDir();
203
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
204
+ expect(formatCliCommand('astryx component Button', dir)).toBe('pnpm exec astryx component Button');
205
+ });
206
+
207
+ it('accepts a bare subcommand (no leading astryx)', () => {
208
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
209
+ const dir = makeTmpDir();
210
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
211
+ expect(formatCliCommand('docs tokens', dir)).toBe('npx astryx docs tokens');
212
+ });
213
+
214
+ it('rewrites to the scoped package for one-off invocations', () => {
215
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
216
+ const dir = makeTmpDir();
217
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
218
+ expect(formatCliCommand('astryx component Button', dir)).toBe('npx @astryxdesign/cli component Button');
219
+ });
220
+ });
@@ -165,21 +165,3 @@ export function isFilePathArg(pathArg) {
165
165
  const ext = path.extname(base).toLowerCase();
166
166
  return ext.length > 0 && FILE_EXTENSIONS.has(ext);
167
167
  }
168
-
169
- /**
170
- * True when the process is running non-interactively (no TTY) or when the
171
- * caller has signaled JSON / scripted use. Commands consult this before
172
- * prompting for confirmation; in scripted mode they require an explicit
173
- * `--overwrite` flag instead.
174
- *
175
- * @param {object} [options]
176
- * @param {boolean} [options.json] - Caller's --json flag.
177
- * @returns {boolean}
178
- */
179
- export function isNonInteractive({json = false} = {}) {
180
- if (json) return true;
181
- // stdin not a TTY means piped input or scripted execution.
182
- if (process.stdin && process.stdin.isTTY === false) return true;
183
- if (process.stdout && process.stdout.isTTY === false) return true;
184
- return false;
185
- }
@@ -14,6 +14,7 @@
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import {semverGt} from './semver.mjs';
17
+ import {getCliInvocation} from './package-manager.mjs';
17
18
 
18
19
  /**
19
20
  * Read the latest available version from local signals.
@@ -75,7 +76,7 @@ export function checkForUpdate(cwd = process.cwd()) {
75
76
  // Use semver-aware comparison so '0.0.20' is correctly treated as greater
76
77
  // than '0.0.5' (lexicographic compare gets that backwards).
77
78
  if (semverGt(latest, installed)) {
78
- return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: astryx upgrade --from <old-version> --apply`;
79
+ return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: ${getCliInvocation()} upgrade --from <old-version> --apply`;
79
80
  }
80
81
 
81
82
  return null;
@@ -7,7 +7,7 @@ export const doc = {
7
7
  name: 'TabList — With Actions',
8
8
  displayName: 'TabList — With Actions',
9
9
  description:
10
- 'Page header pattern with tabs on the left and action buttons pushed to the right. When hasDivider is true, pair with a smaller button size (sm) so actions don\'t overpower the tab row.',
10
+ 'Page header pattern with tabs on the left and action buttons pushed to the right. When hasDivider is true, match the Button size to the TabList size so the tabs and actions align to a shared baseline above the divider.',
11
11
  isReady: true,
12
12
  aspectRatio: 4 / 3,
13
13
  componentsUsed: ['TabList', 'Tab', 'Button'],
@@ -55,16 +55,11 @@ export default function TabListTabsWithActions() {
55
55
  <Button
56
56
  label="Filter"
57
57
  variant="ghost"
58
- size="sm"
58
+ size="lg"
59
59
  icon={FilterIcon}
60
60
  isIconOnly
61
61
  />
62
- <Button
63
- label="New item"
64
- variant="primary"
65
- size="sm"
66
- icon={PlusIcon}
67
- />
62
+ <Button label="New item" variant="primary" size="lg" icon={PlusIcon} />
68
63
  </div>
69
64
  </TabList>
70
65
  );