@astryxdesign/cli 0.1.7-canary.e486729 → 0.1.7-canary.eb8e07b
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/docs/getting-started.doc.mjs +1 -1
- package/package.json +10 -9
- 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 +1 -1
- package/src/commands/build-theme.mjs +65 -4
- package/src/commands/init.mjs +146 -42
- 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/swizzle.mjs +29 -5
- package/src/commands/template.mjs +27 -5
- package/src/commands/upgrade.mjs +1 -1
- package/src/commands/upgrade.test.mjs +1 -1
- package/src/index.mjs +2 -2
- package/src/utils/interactive.mjs +76 -0
- package/src/utils/interactive.test.mjs +70 -0
- package/src/utils/path-safety.mjs +18 -0
- package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
- package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +7 -2
- package/src/lib/term-log.mjs +0 -48
|
@@ -40,7 +40,7 @@ export const docs = {
|
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
type: 'prose',
|
|
43
|
-
text:
|
|
43
|
+
text: 'Then run the init wizard to set up AI agent docs, pick a starter template, and learn about theming.',
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
type: 'code',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.1.7-canary.
|
|
3
|
+
"version": "0.1.7-canary.eb8e07b",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -72,16 +72,17 @@
|
|
|
72
72
|
"CHANGELOG.md"
|
|
73
73
|
],
|
|
74
74
|
"dependencies": {
|
|
75
|
+
"@clack/prompts": "^1.7.0",
|
|
75
76
|
"commander": "^12.1.0",
|
|
76
77
|
"jiti": "^2.7.0",
|
|
77
78
|
"jscodeshift": "^17.3.0",
|
|
78
79
|
"zod": "^4.4.3"
|
|
79
80
|
},
|
|
80
81
|
"peerDependencies": {
|
|
81
|
-
"@astryxdesign/charts": "0.1.7-canary.
|
|
82
|
-
"@astryxdesign/core": "0.1.7-canary.
|
|
83
|
-
"@astryxdesign/lab": "0.1.7-canary.
|
|
84
|
-
"@astryxdesign/theme-neutral": "0.1.7-canary.
|
|
82
|
+
"@astryxdesign/charts": "0.1.7-canary.eb8e07b",
|
|
83
|
+
"@astryxdesign/core": "0.1.7-canary.eb8e07b",
|
|
84
|
+
"@astryxdesign/lab": "0.1.7-canary.eb8e07b",
|
|
85
|
+
"@astryxdesign/theme-neutral": "0.1.7-canary.eb8e07b",
|
|
85
86
|
"gpt-tokenizer": "^3.4.0"
|
|
86
87
|
},
|
|
87
88
|
"peerDependenciesMeta": {
|
|
@@ -99,10 +100,10 @@
|
|
|
99
100
|
}
|
|
100
101
|
},
|
|
101
102
|
"devDependencies": {
|
|
102
|
-
"@astryxdesign/charts": "0.1.7-canary.
|
|
103
|
-
"@astryxdesign/core": "0.1.7-canary.
|
|
104
|
-
"@astryxdesign/lab": "0.1.7-canary.
|
|
105
|
-
"@astryxdesign/theme-neutral": "0.1.7-canary.
|
|
103
|
+
"@astryxdesign/charts": "0.1.7-canary.eb8e07b",
|
|
104
|
+
"@astryxdesign/core": "0.1.7-canary.eb8e07b",
|
|
105
|
+
"@astryxdesign/lab": "0.1.7-canary.eb8e07b",
|
|
106
|
+
"@astryxdesign/theme-neutral": "0.1.7-canary.eb8e07b",
|
|
106
107
|
"gpt-tokenizer": "^3.4.0"
|
|
107
108
|
},
|
|
108
109
|
"scripts": {
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file Lazy jscodeshift installer
|
|
5
5
|
*
|
|
6
|
-
* Checks if jscodeshift is available and
|
|
6
|
+
* Checks if jscodeshift is available and offers to install it on-demand.
|
|
7
7
|
* Keeps the CLI lean — jscodeshift is only needed for codemods.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* In non-interactive environments (CI, LLM agents), the interactive prompt
|
|
10
|
+
* is skipped. Pass `installDeps: true` to auto-install without prompting,
|
|
11
|
+
* or the command will fail with a helpful error message.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import {execSync} from 'node:child_process';
|
|
14
|
-
import * as p from '
|
|
15
|
+
import * as p from '@clack/prompts';
|
|
15
16
|
import {detectPackageManager} from '../utils/package-manager.mjs';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -29,17 +30,32 @@ export async function ensureJscodeshift({installDeps = false, silent = false} =
|
|
|
29
30
|
} catch {
|
|
30
31
|
log.warn('jscodeshift is required for codemods but not installed.');
|
|
31
32
|
|
|
33
|
+
const isInteractive = process.stdout.isTTY && !process.env.CI;
|
|
34
|
+
|
|
32
35
|
if (installDeps) {
|
|
33
|
-
// Explicit opt-in — install without prompting
|
|
36
|
+
// Explicit opt-in — install without prompting
|
|
34
37
|
return installJscodeshift(silent);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
if (!isInteractive || silent) {
|
|
41
|
+
// Non-interactive environment (or --json) — fail fast with a helpful message
|
|
42
|
+
log.error(
|
|
43
|
+
'Cannot run codemods without jscodeshift. ' +
|
|
44
|
+
'Use --install-deps to auto-install in non-interactive environments.',
|
|
45
|
+
);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Interactive TTY — prompt as before
|
|
50
|
+
const shouldInstall = await p.confirm({
|
|
51
|
+
message: 'Install jscodeshift now?',
|
|
52
|
+
initialValue: true,
|
|
53
|
+
});
|
|
54
|
+
if (p.isCancel(shouldInstall) || !shouldInstall) {
|
|
55
|
+
p.log.error('Cannot run codemods without jscodeshift.');
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return installJscodeshift(silent);
|
|
43
59
|
}
|
|
44
60
|
}
|
|
45
61
|
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import * as fs from 'node:fs';
|
|
31
31
|
import * as path from 'node:path';
|
|
32
|
-
import * as p from '
|
|
32
|
+
import * as p from '@clack/prompts';
|
|
33
33
|
import {findConfigPath} from '../lib/project.mjs';
|
|
34
34
|
import {fixDirectiveCorruption, validateOutput} from './runner.mjs';
|
|
35
35
|
|
package/src/codemods/runner.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
12
|
import * as path from 'node:path';
|
|
13
|
-
import * as p from '
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
14
|
import {humanLog} from '../lib/json.mjs';
|
|
15
15
|
import {runConfigCodemod} from './run-codemod.mjs';
|
|
16
16
|
|
|
@@ -184,7 +184,7 @@ export async function runCodemods(
|
|
|
184
184
|
versionManifests,
|
|
185
185
|
{apply, path: srcPath, codemod, skipCodemods, silent = false},
|
|
186
186
|
) {
|
|
187
|
-
// No-op stub object so silent mode skips
|
|
187
|
+
// No-op stub object so silent mode skips clack stdout entirely without
|
|
188
188
|
// littering the body with `if (!silent)` guards.
|
|
189
189
|
const log = silent
|
|
190
190
|
? {step() {}, info() {}, success() {}, warn() {}, error() {}, message() {}}
|
|
@@ -392,7 +392,7 @@ export function removeAgentDocs(targetDir) {
|
|
|
392
392
|
|
|
393
393
|
/**
|
|
394
394
|
* Programmatic entry point for installing agent docs.
|
|
395
|
-
* Used by the init
|
|
395
|
+
* Used by the init wizard, upgrade command, and agent-docs command.
|
|
396
396
|
*
|
|
397
397
|
* Strategy (when no agent/paths specified):
|
|
398
398
|
* - Discover all existing agent doc files and update them
|
|
@@ -21,11 +21,12 @@ import {getRunPrefix} from '../utils/package-manager.mjs';
|
|
|
21
21
|
import {
|
|
22
22
|
sanitizeName,
|
|
23
23
|
PathSafetyError,
|
|
24
|
+
isNonInteractive,
|
|
24
25
|
} from '../utils/path-safety.mjs';
|
|
25
26
|
import {jsonOut, humanLog} from '../lib/json.mjs';
|
|
26
27
|
import {cliError} from '../lib/cli-error.mjs';
|
|
27
28
|
import {ERROR_CODES} from '../lib/error-codes.mjs';
|
|
28
|
-
import {themeAdd} from '../api/theme-add.mjs';
|
|
29
|
+
import {themeAdd, listThemes} from '../api/theme-add.mjs';
|
|
29
30
|
|
|
30
31
|
// Import shared theme processing from core. `astryx theme build` MUST produce the
|
|
31
32
|
// exact same CSS as the `<Theme>` runtime, so it has exactly one generation
|
|
@@ -1140,9 +1141,32 @@ Or with a <link> tag:
|
|
|
1140
1141
|
.action(async (slug, targetPath, options) => {
|
|
1141
1142
|
const json = program.opts().json || false;
|
|
1142
1143
|
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
// ERR_FILE_EXISTS guard
|
|
1144
|
+
// Only prompt with a real TTY on stdin — a piped/redirected stdin would
|
|
1145
|
+
// make clack hang. Non-interactive callers fall through to the API's
|
|
1146
|
+
// ERR_FILE_EXISTS guard.
|
|
1147
|
+
const interactive =
|
|
1148
|
+
!json && !isNonInteractive({json}) && Boolean(process.stdin.isTTY);
|
|
1149
|
+
if (slug && !options.list && !options.overwrite && interactive) {
|
|
1150
|
+
const collision = await detectThemeCollision(slug, targetPath);
|
|
1151
|
+
if (collision) {
|
|
1152
|
+
const rel = path.relative(process.cwd(), collision) || collision;
|
|
1153
|
+
const p = await import('@clack/prompts');
|
|
1154
|
+
const confirmed = await p.confirm({
|
|
1155
|
+
message: `Overwrite existing file ${rel}?`,
|
|
1156
|
+
initialValue: false,
|
|
1157
|
+
});
|
|
1158
|
+
if (p.isCancel(confirmed)) {
|
|
1159
|
+
p.cancel('Cancelled.');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (!confirmed) {
|
|
1163
|
+
humanLog('Aborted. Re-run with --overwrite to replace the file.');
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
options.overwrite = true;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1146
1170
|
let result;
|
|
1147
1171
|
try {
|
|
1148
1172
|
result = await themeAdd(slug, {
|
|
@@ -1195,3 +1219,40 @@ This is your copy of the ${displayName} theme — edit ${entry} to make it your
|
|
|
1195
1219
|
});
|
|
1196
1220
|
}
|
|
1197
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* First existing file that scaffolding <slug> into <targetPath> would clobber,
|
|
1224
|
+
* or null. Used to prompt before invoking the API; the API re-validates and
|
|
1225
|
+
* owns any authoritative error.
|
|
1226
|
+
*
|
|
1227
|
+
* @param {string} slug
|
|
1228
|
+
* @param {string} [targetPath]
|
|
1229
|
+
* @returns {Promise<string|null>}
|
|
1230
|
+
*/
|
|
1231
|
+
async function detectThemeCollision(slug, targetPath) {
|
|
1232
|
+
let themes;
|
|
1233
|
+
try {
|
|
1234
|
+
themes = listThemes();
|
|
1235
|
+
} catch {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
const match = themes.find(t => t.slug.toLowerCase() === slug.toLowerCase());
|
|
1239
|
+
if (!match) return null;
|
|
1240
|
+
|
|
1241
|
+
const rawTarget = targetPath || path.join('src', 'themes', match.slug);
|
|
1242
|
+
let resolvedDir;
|
|
1243
|
+
try {
|
|
1244
|
+
// Fail soft (null) on traversal; the API surfaces the real error.
|
|
1245
|
+
const {assertWithin} = await import('../utils/path-safety.mjs');
|
|
1246
|
+
resolvedDir = assertWithin(rawTarget, process.cwd(), {
|
|
1247
|
+
label: 'theme target path',
|
|
1248
|
+
});
|
|
1249
|
+
} catch {
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
for (const name of match.files) {
|
|
1254
|
+
const dest = path.join(resolvedDir, name);
|
|
1255
|
+
if (fs.existsSync(dest)) return dest;
|
|
1256
|
+
}
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @file init command —
|
|
4
|
+
* @file init command — Interactive initialization wizard + feature installer
|
|
5
5
|
*
|
|
6
|
-
* `astryx init`
|
|
7
|
-
*
|
|
8
|
-
* AI agents, CI, and piped I/O — it never hangs or errors on a missing TTY.
|
|
9
|
-
* Non-interactive feature install: `astryx init --features agents,theme,template`
|
|
6
|
+
* Interactive: `astryx init` walks through all features
|
|
7
|
+
* Non-interactive: `astryx init --features agents,theme,template`
|
|
10
8
|
* Re-runnable: safe to run multiple times, idempotent
|
|
11
9
|
*
|
|
12
10
|
* Features:
|
|
13
11
|
* agents — Install AGENTS.md/CLAUDE.md cheat sheet for AI coding agents
|
|
14
|
-
* theme —
|
|
12
|
+
* theme — Scaffold a custom theme file
|
|
15
13
|
* template — Copy a starter page template
|
|
16
14
|
*/
|
|
17
15
|
|
|
16
|
+
import * as p from '@clack/prompts';
|
|
18
17
|
import * as path from 'node:path';
|
|
19
18
|
import * as fs from 'node:fs';
|
|
20
19
|
import {CLI_ROOT} from '../utils/paths.mjs';
|
|
@@ -25,6 +24,7 @@ import {listTemplates} from './template.mjs';
|
|
|
25
24
|
import {humanLog} from '../lib/json.mjs';
|
|
26
25
|
import {cliError} from '../lib/cli-error.mjs';
|
|
27
26
|
import {ERROR_CODES} from '../lib/error-codes.mjs';
|
|
27
|
+
import {requireInteractive} from '../utils/interactive.mjs';
|
|
28
28
|
|
|
29
29
|
const VALID_FEATURES = ['agents', 'theme', 'template'];
|
|
30
30
|
const run = getRunPrefix();
|
|
@@ -60,9 +60,17 @@ export function getNextSteps(runPrefix) {
|
|
|
60
60
|
];
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function isCancel(value) {
|
|
64
|
+
if (p.isCancel(value)) {
|
|
65
|
+
p.cancel('Setup cancelled.');
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
// ─── Feature: agents ─────────────────────────────────────────────────────────
|
|
64
72
|
|
|
65
|
-
function runAgents(targetDir, {agent, agentDocsPath} = {}) {
|
|
73
|
+
function runAgents(targetDir, {interactive = true, agent, agentDocsPath} = {}) {
|
|
66
74
|
try {
|
|
67
75
|
const paths = agentDocsPath
|
|
68
76
|
? Array.isArray(agentDocsPath)
|
|
@@ -71,55 +79,114 @@ function runAgents(targetDir, {agent, agentDocsPath} = {}) {
|
|
|
71
79
|
: undefined;
|
|
72
80
|
const written = installAgentDocs(targetDir, {agent, paths});
|
|
73
81
|
const summary = written.join(', ');
|
|
74
|
-
|
|
82
|
+
if (interactive) {
|
|
83
|
+
p.log.success(`AI agent docs installed → ${summary}`);
|
|
84
|
+
} else {
|
|
85
|
+
humanLog(`✓ AI agent docs installed → ${summary}`);
|
|
86
|
+
}
|
|
75
87
|
} catch (err) {
|
|
76
88
|
// PathSafetyError carries a precise, user-actionable message —
|
|
77
89
|
// surface it instead of the generic "could not install" warning so
|
|
78
90
|
// misconfigured --agent-docs-path values aren't silently swallowed.
|
|
79
91
|
if (err instanceof PathSafetyError) {
|
|
80
|
-
|
|
92
|
+
const msg = `Error: ${err.message}`;
|
|
93
|
+
if (interactive) {
|
|
94
|
+
p.log.error(msg);
|
|
95
|
+
} else {
|
|
96
|
+
console.error(msg);
|
|
97
|
+
}
|
|
81
98
|
process.exitCode = 1;
|
|
82
99
|
return;
|
|
83
100
|
}
|
|
84
|
-
|
|
101
|
+
const msg = `Could not install agent docs. Try again with \`${run} astryx init --features agents\`.`;
|
|
102
|
+
if (interactive) {
|
|
103
|
+
p.log.warning(msg);
|
|
104
|
+
} else {
|
|
105
|
+
console.error(msg);
|
|
106
|
+
}
|
|
85
107
|
}
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
// ─── Feature: theme ──────────────────────────────────────────────────────────
|
|
89
111
|
|
|
90
|
-
function runTheme() {
|
|
91
|
-
|
|
112
|
+
async function runTheme({interactive = true} = {}) {
|
|
113
|
+
if (!interactive) {
|
|
114
|
+
humanLog(`✓ Theme scaffolding requires interactive mode. Run \`${run} astryx theme\` instead.`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
p.note(
|
|
119
|
+
'Create a custom theme with your brand colors.\n' +
|
|
120
|
+
`Run \`${run} astryx theme\` for the full theme wizard.\n` +
|
|
121
|
+
`Run \`${run} astryx theme --list\` to see existing themes.`,
|
|
122
|
+
'Themes',
|
|
123
|
+
);
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
// ─── Feature: template ───────────────────────────────────────────────────────
|
|
95
127
|
|
|
96
|
-
function runTemplate(targetDir, {templateName} = {}) {
|
|
128
|
+
async function runTemplate(targetDir, {interactive = true, templateName} = {}) {
|
|
97
129
|
const templates = listTemplates();
|
|
98
130
|
if (templates.length === 0) return;
|
|
99
131
|
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
if (!interactive) {
|
|
133
|
+
if (!templateName) {
|
|
134
|
+
// Point agents at the build workflow rather than dumping page-template
|
|
135
|
+
// names — `build` surfaces pages AND blocks AND components for an idea,
|
|
136
|
+
// and `build` with no args is the full how-to-build playbook.
|
|
137
|
+
humanLog('✓ To build UI, use these commands:');
|
|
138
|
+
humanLog('');
|
|
139
|
+
humanLog(` ${run} astryx build "<what you're building>" build a page — kit: closest template + blocks + components`);
|
|
140
|
+
humanLog(` ${run} astryx build the how-to-build workflow (read this first)`);
|
|
141
|
+
humanLog(` ${run} astryx search <query> find anything — components, docs, templates, blocks`);
|
|
142
|
+
humanLog('');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!templates.includes(templateName)) {
|
|
147
|
+
cliError(`Unknown template "${templateName}". Available: ${templates.join(', ')}`, {code: ERROR_CODES.ERR_UNKNOWN_TEMPLATE});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
112
150
|
|
|
113
|
-
|
|
114
|
-
|
|
151
|
+
const outputDir = path.resolve(targetDir, `./src/pages/${templateName}`);
|
|
152
|
+
const srcPath = path.join(CLI_ROOT, 'templates', 'pages', templateName, 'page.tsx');
|
|
153
|
+
fs.mkdirSync(outputDir, {recursive: true});
|
|
154
|
+
fs.copyFileSync(srcPath, path.join(outputDir, 'page.tsx'));
|
|
155
|
+
humanLog(`✓ Template created at ${path.relative(targetDir, outputDir)}/page.tsx`);
|
|
115
156
|
return;
|
|
116
157
|
}
|
|
117
158
|
|
|
118
|
-
const
|
|
119
|
-
|
|
159
|
+
const TEMPLATE_OPTIONS = [
|
|
160
|
+
{value: 'skip', label: 'Skip — No template'},
|
|
161
|
+
{value: 'blank', label: 'Blank Page — Minimal scaffold'},
|
|
162
|
+
{value: 'table', label: 'Table Page — Data table with actions'},
|
|
163
|
+
{value: 'login', label: 'Login Page — Auth form with inputs'},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const templateChoice = isCancel(
|
|
167
|
+
await p.select({
|
|
168
|
+
message: 'Start with a page template?',
|
|
169
|
+
options: TEMPLATE_OPTIONS,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (templateChoice === 'skip') return;
|
|
174
|
+
|
|
175
|
+
const targetPath = isCancel(
|
|
176
|
+
await p.text({
|
|
177
|
+
message: 'Where should the template be created?',
|
|
178
|
+
initialValue: `./src/pages/${templateChoice}`,
|
|
179
|
+
placeholder: `./src/pages/${templateChoice}`,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const outputDir = path.resolve(targetDir, targetPath);
|
|
184
|
+
const srcPath = path.join(CLI_ROOT, 'templates', 'pages', templateChoice, 'page.tsx');
|
|
185
|
+
|
|
120
186
|
fs.mkdirSync(outputDir, {recursive: true});
|
|
121
187
|
fs.copyFileSync(srcPath, path.join(outputDir, 'page.tsx'));
|
|
122
|
-
|
|
188
|
+
|
|
189
|
+
p.log.success(`Template created at ${path.relative(targetDir, outputDir)}/page.tsx`);
|
|
123
190
|
}
|
|
124
191
|
|
|
125
192
|
// ─── Command ─────────────────────────────────────────────────────────────────
|
|
@@ -133,7 +200,7 @@ export function registerInit(program) {
|
|
|
133
200
|
.option('--remove-agents', 'Remove AI agent docs from all agent doc files')
|
|
134
201
|
.option('--agent <tool>', 'Target AI tool for agent docs: claude, cursor, codex, hermes, all')
|
|
135
202
|
.option('--agent-docs-path <path...>', 'Explicit file path(s) for agent docs')
|
|
136
|
-
.action((options) => {
|
|
203
|
+
.action(async (options) => {
|
|
137
204
|
const targetDir = process.cwd();
|
|
138
205
|
|
|
139
206
|
// Remove mode
|
|
@@ -157,25 +224,62 @@ export function registerInit(program) {
|
|
|
157
224
|
|
|
158
225
|
for (const feature of features) {
|
|
159
226
|
if (feature === 'agents') runAgents(targetDir, {
|
|
227
|
+
interactive: false,
|
|
160
228
|
agent: options.agent,
|
|
161
229
|
agentDocsPath: options.agentDocsPath,
|
|
162
230
|
});
|
|
163
|
-
if (feature === 'theme') runTheme();
|
|
164
|
-
if (feature === 'template') runTemplate(targetDir, {});
|
|
231
|
+
if (feature === 'theme') await runTheme({interactive: false});
|
|
232
|
+
if (feature === 'template') await runTemplate(targetDir, {interactive: false});
|
|
165
233
|
}
|
|
166
234
|
return;
|
|
167
235
|
}
|
|
168
236
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
237
|
+
// Interactive wizard
|
|
238
|
+
//
|
|
239
|
+
// Guard: this wizard blocks on prompts. In a non-interactive context
|
|
240
|
+
// (CI, piped stdin/stdout, no TTY) it would hang forever. Fail fast
|
|
241
|
+
// with actionable guidance via the shared interactivity contract.
|
|
242
|
+
requireInteractive({
|
|
243
|
+
command: 'init',
|
|
244
|
+
hint: `\`${run} astryx init --all\` or \`--features agents,theme,template\``,
|
|
176
245
|
});
|
|
177
|
-
|
|
178
|
-
|
|
246
|
+
|
|
247
|
+
p.intro('Welcome to the design system');
|
|
248
|
+
|
|
249
|
+
p.note(
|
|
250
|
+
'A design system for building internal tools\nwith 300+ React components.',
|
|
251
|
+
'About',
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Feature: agents
|
|
255
|
+
const shouldInstallAgents = isCancel(
|
|
256
|
+
await p.confirm({
|
|
257
|
+
message: 'Install AI agent support? (adds a design system cheat sheet to AGENTS.md)',
|
|
258
|
+
initialValue: true,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (shouldInstallAgents) {
|
|
263
|
+
const s = p.spinner();
|
|
264
|
+
s.start('Installing agent docs');
|
|
265
|
+
runAgents(targetDir);
|
|
266
|
+
s.stop('Done');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Feature: swizzle awareness
|
|
270
|
+
p.note(
|
|
271
|
+
`You can customize any component with:\n ${run} astryx swizzle Button\n ${run} astryx swizzle --list`,
|
|
272
|
+
'Component Customization',
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Feature: template
|
|
276
|
+
await runTemplate(targetDir);
|
|
277
|
+
|
|
278
|
+
// Feature: theme awareness
|
|
279
|
+
await runTheme();
|
|
280
|
+
|
|
281
|
+
// Outro
|
|
282
|
+
p.outro('Design system initialized!');
|
|
179
283
|
|
|
180
284
|
for (const line of getNextSteps(run)) {
|
|
181
285
|
humanLog(line);
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @file Subprocess no-hang tests for
|
|
4
|
+
* @file Subprocess no-hang tests for the interactivity contract.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* /
|
|
10
|
-
*
|
|
6
|
+
* The wizard command `init` blocks on @clack/prompts. In a
|
|
7
|
+
* non-interactive context it must fail fast (exit 1) instead of hanging.
|
|
8
|
+
* These tests spawn the CLI as a real subprocess with stdin/stdout NOT a TTY
|
|
9
|
+
* (stdio 'ignore'/'pipe'), which is exactly the CI / piped condition. If the
|
|
10
|
+
* guard were missing, spawnSync would hit the timeout (signal SIGTERM,
|
|
11
|
+
* status null) — so asserting `signal === null && status === 1` proves the
|
|
12
|
+
* process exited cleanly rather than hanging.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
@@ -40,24 +42,25 @@ afterEach(() => {
|
|
|
40
42
|
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
41
43
|
});
|
|
42
44
|
|
|
43
|
-
describe('init
|
|
44
|
-
it('
|
|
45
|
+
describe('init non-interactive safety', () => {
|
|
46
|
+
it('fails fast (exit 1, no hang) and writes no files', () => {
|
|
45
47
|
const r = runCli(['init']);
|
|
46
|
-
expect(r.signal).toBeNull();
|
|
47
|
-
expect(r.status).toBe(
|
|
48
|
-
expect(fs.readdirSync(tmpDir)
|
|
48
|
+
expect(r.signal).toBeNull();
|
|
49
|
+
expect(r.status).toBe(1);
|
|
50
|
+
expect(fs.readdirSync(tmpDir)).toEqual([]);
|
|
49
51
|
});
|
|
50
52
|
|
|
51
|
-
it('
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
53
|
+
it('prints actionable guidance (--all / --features)', () => {
|
|
54
|
+
const out = (() => {
|
|
55
|
+
const r = runCli(['init']);
|
|
56
|
+
return r.stderr + r.stdout;
|
|
57
|
+
})();
|
|
58
|
+
expect(out).toMatch(/requires a TTY/i);
|
|
59
|
+
expect(out).toMatch(/--all/);
|
|
60
|
+
expect(out).toMatch(/--features/);
|
|
58
61
|
});
|
|
59
62
|
|
|
60
|
-
it('still runs --features agents non-interactively', () => {
|
|
63
|
+
it('still runs --features agents non-interactively (guard does not over-catch)', () => {
|
|
61
64
|
const r = runCli(['init', '--features', 'agents']);
|
|
62
65
|
expect(r.signal).toBeNull();
|
|
63
66
|
expect(r.status).toBe(0);
|
|
@@ -45,7 +45,7 @@ function runCli(args, {cwd} = {}) {
|
|
|
45
45
|
|
|
46
46
|
function parseJson(stdout) {
|
|
47
47
|
// CLI emits a single JSON document. If anything else snuck onto stdout
|
|
48
|
-
// (
|
|
48
|
+
// (clack output, console.log strings, etc.) JSON.parse will throw —
|
|
49
49
|
// which is exactly the failure mode we want to catch in tests.
|
|
50
50
|
return JSON.parse(stdout);
|
|
51
51
|
}
|
package/src/commands/layout.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import {layoutExpand, layoutCheck, layoutGrammar} from '../api/layout.mjs';
|
|
|
20
20
|
/** Resolve the expression from arg, --file, or stdin ('-'). */
|
|
21
21
|
async function readExpression(expr, options) {
|
|
22
22
|
if (options.file) return fs.readFileSync(options.file, 'utf-8');
|
|
23
|
-
if (expr === '-') {
|
|
23
|
+
if (expr === '-' || (!expr && !process.stdin.isTTY)) {
|
|
24
24
|
const chunks = [];
|
|
25
25
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
26
26
|
return Buffer.concat(chunks).toString('utf-8');
|
package/src/commands/swizzle.mjs
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
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';
|
|
@@ -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
|
|
@@ -304,11 +314,25 @@ export function registerSwizzle(program) {
|
|
|
304
314
|
|
|
305
315
|
if (existingFiles.length > 0 && !options.overwrite) {
|
|
306
316
|
const relOutputForMsg = path.relative(process.cwd(), outputDir) || '.';
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
}
|
|
312
336
|
}
|
|
313
337
|
|
|
314
338
|
fs.mkdirSync(outputDir, {recursive: true});
|
|
@@ -6,6 +6,8 @@
|
|
|
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';
|
|
@@ -15,6 +17,14 @@ import {warnOnIntegrationIssues} from '../lib/integration-warnings.mjs';
|
|
|
15
17
|
|
|
16
18
|
export {discoverTemplates, listTemplates} from '../api/template.mjs';
|
|
17
19
|
|
|
20
|
+
function isCancel(value) {
|
|
21
|
+
if (p.isCancel(value)) {
|
|
22
|
+
p.cancel('Cancelled.');
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
export function registerTemplate(program) {
|
|
19
29
|
program
|
|
20
30
|
.command('template [name] [path]')
|
|
@@ -49,11 +59,23 @@ export function registerTemplate(program) {
|
|
|
49
59
|
const collision = await detectTemplateCollision(name, targetPath);
|
|
50
60
|
if (collision && !options.overwrite) {
|
|
51
61
|
const rel = path.relative(process.cwd(), collision) || collision;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
79
|
}
|
|
58
80
|
}
|
|
59
81
|
|
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';
|
|
@@ -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
|
@@ -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
|
|
177
|
+
* action (so no filesystem mutations, no clack 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
|
|
@@ -323,7 +323,7 @@ ${line('')}
|
|
|
323
323
|
${line(' Design system installed!')}
|
|
324
324
|
${line('')}
|
|
325
325
|
${line(' Get started:')}
|
|
326
|
-
${line(` ${r} init
|
|
326
|
+
${line(` ${r} init Interactive setup`)}
|
|
327
327
|
${line(` ${r} --help See all commands`)}
|
|
328
328
|
${line('')}
|
|
329
329
|
${line(' Or run directly:')}
|
|
@@ -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
|
+
});
|
|
@@ -165,3 +165,21 @@ 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
|
+
}
|
|
@@ -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,
|
|
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.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
aspectRatio: 4 / 3,
|
|
13
13
|
componentsUsed: ['TabList', 'Tab', 'Button'],
|
|
@@ -55,11 +55,16 @@ export default function TabListTabsWithActions() {
|
|
|
55
55
|
<Button
|
|
56
56
|
label="Filter"
|
|
57
57
|
variant="ghost"
|
|
58
|
-
size="
|
|
58
|
+
size="sm"
|
|
59
59
|
icon={FilterIcon}
|
|
60
60
|
isIconOnly
|
|
61
61
|
/>
|
|
62
|
-
<Button
|
|
62
|
+
<Button
|
|
63
|
+
label="New item"
|
|
64
|
+
variant="primary"
|
|
65
|
+
size="sm"
|
|
66
|
+
icon={PlusIcon}
|
|
67
|
+
/>
|
|
63
68
|
</div>
|
|
64
69
|
</TabList>
|
|
65
70
|
);
|
package/src/lib/term-log.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
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
|
-
}
|