@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.
- package/README.md +19 -115
- package/docs/getting-started.doc.mjs +9 -9
- package/docs/integration-authoring.md +105 -0
- package/docs/migration.doc.mjs +18 -18
- package/docs/principles.doc.dense.mjs +1 -1
- package/docs/principles.doc.mjs +6 -6
- package/docs/principles.doc.zh.mjs +1 -1
- package/docs/styling-libraries.doc.mjs +3 -3
- package/docs/styling.doc.mjs +4 -4
- package/docs/theme.doc.dense.mjs +2 -2
- package/docs/theme.doc.mjs +7 -7
- package/docs/theme.doc.zh.mjs +1 -1
- package/docs/tokens.doc.mjs +1 -1
- package/docs/working-with-ai.doc.mjs +18 -18
- package/package.json +10 -11
- package/src/api/doctor.mjs +3 -3
- package/src/codemods/ensure-jscodeshift.mjs +27 -11
- package/src/codemods/run-codemod.mjs +1 -1
- package/src/codemods/runner.mjs +2 -2
- package/src/commands/agent-docs.mjs +14 -56
- package/src/commands/agent-docs.test.mjs +4 -11
- package/src/commands/build-theme.mjs +71 -10
- package/src/commands/build.mjs +15 -15
- package/src/commands/component/index.mjs +4 -4
- package/src/commands/discover.mjs +5 -7
- package/src/commands/docs.mjs +4 -4
- package/src/commands/hook/index.mjs +4 -4
- package/src/commands/init.mjs +152 -48
- package/src/commands/init.next-steps.test.mjs +1 -1
- package/src/commands/interactive-guard.test.mjs +22 -19
- package/src/commands/json-contract.test.mjs +1 -1
- package/src/commands/layout.mjs +1 -1
- package/src/commands/search.mjs +4 -4
- package/src/commands/swizzle.mjs +34 -11
- package/src/commands/template.mjs +31 -11
- package/src/commands/upgrade.mjs +6 -9
- package/src/commands/upgrade.test.mjs +1 -1
- package/src/index.mjs +6 -40
- package/src/lib/component-format.mjs +1 -2
- package/src/utils/interactive.mjs +76 -0
- package/src/utils/interactive.test.mjs +70 -0
- package/src/utils/package-manager.mjs +0 -78
- package/src/utils/package-manager.test.mjs +1 -108
- package/src/utils/path-safety.mjs +18 -0
- package/src/utils/update-check.mjs +1 -2
- package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
- package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +7 -2
- package/docs/cli-integrations.doc.mjs +0 -150
- package/scripts/postinstall.mjs +0 -74
- package/src/commands/cli-postinstall.test.mjs +0 -42
- package/src/commands/setup-nudge.test.mjs +0 -108
- package/src/lib/term-log.mjs +0 -48
package/src/commands/swizzle.mjs
CHANGED
|
@@ -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 {
|
|
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:
|
|
202
|
-
humanLog(
|
|
210
|
+
humanLog(`\nUsage: astryx swizzle <component>\n`);
|
|
211
|
+
humanLog('Example: astryx swizzle Button');
|
|
203
212
|
humanLog(
|
|
204
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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(` ${
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
104
|
-
humanLog(
|
|
105
|
-
humanLog(
|
|
106
|
-
humanLog(
|
|
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
|
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -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 '
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(` ${
|
|
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 \`${
|
|
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
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
}
|