@astryxdesign/cli 0.4.3-canary.ac850d9 → 0.4.3-canary.b3dd679
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 +1 -0
- package/api/discover/_adapter.d.mts +5 -3
- package/api/discover/_adapter.mjs +6 -4
- package/api/theme/theme.type.d.mts +16 -0
- package/api/theme/theme.type.mjs +11 -0
- package/clients/cli/commands/build-theme.mjs +154 -65
- package/clients/cli/commands/build-theme.multi.test.mjs +148 -0
- package/clients/cli/commands/build-theme.watch.test.mjs +67 -0
- package/clients/cli/commands/discover.broken-integration.test.mjs +112 -0
- package/clients/cli/commands/discover.mjs +12 -0
- package/clients/cli/commands/search.mjs +9 -0
- package/clients/cli/commands/theme-build.doc.mjs +11 -5
- package/clients/cli/lib/json-shim.test.mjs +1 -1
- package/clients/cli/lib/manifest.mjs +1 -1
- package/clients/cli/lib/manifest.test.mjs +4 -2
- package/foundation/integrations/integration-warnings.test.mjs +17 -0
- package/foundation/integrations/validate-contributions.mjs +7 -0
- package/foundation/response/response-types.doc.mjs +5 -0
- package/package.json +12 -9
package/README.md
CHANGED
|
@@ -415,6 +415,7 @@ Every response has a `type` discriminant. The full set is below (generated from
|
|
|
415
415
|
| `hook.detail.params` | Just one hook's parameters table (HookParamDoc[]). |
|
|
416
416
|
| `theme.build` | A theme build receipt: name, token- and component-override counts, output size, the written outputs {css, js, dts, and variantsDts when applicable}, and any validation warnings. |
|
|
417
417
|
| `theme.build.check` | The --check receipt: theme name, an upToDate flag, the stale outputs (each {path, reason: missing \| outdated}), and the full list of checked paths. Writes nothing. |
|
|
418
|
+
| `theme.build.batch` | Several themes built in one invocation: `count` plus one {file, receipt} per theme in argument order, where receipt is that theme's theme.build (or theme.build.check) envelope, or null when it produced no CSS. |
|
|
418
419
|
| `theme.list` | Every bundled theme as a ThemeListEntry[]: each with slug, displayName, description, and a maintained flag. |
|
|
419
420
|
| `theme.add` | A scaffold receipt: resolved slug, displayName, maintained flag, outputDir (relative to cwd), the theme entry file, its exportName, and the files written. |
|
|
420
421
|
| `theme.template` | A write receipt for the annotated theme template: the path (relative to cwd), whether it was written, and the reason it was not — `exists` when a file was already there, which is a success. |
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* External packages come from configured integrations that declare a components
|
|
8
8
|
* root; each becomes a scannable package keyed by its docsDir. `configured`
|
|
9
|
-
* reports whether
|
|
10
|
-
*
|
|
11
|
-
* nothing
|
|
9
|
+
* reports whether the project configured ANY integration — including one whose
|
|
10
|
+
* manifest failed to load, which contributes nothing but is emphatically not
|
|
11
|
+
* "nothing configured". It lets an empty result distinguish "nothing
|
|
12
|
+
* configured" (`false`) from "configured but nothing discovered" (`true`),
|
|
13
|
+
* which the list leaf surfaces as `meta`.
|
|
12
14
|
*
|
|
13
15
|
* @returns {Promise<{packages: ScannedPackage[], configured: boolean}>}
|
|
14
16
|
*/
|
|
@@ -62,9 +62,11 @@ function validateDocs(docs) {
|
|
|
62
62
|
*
|
|
63
63
|
* External packages come from configured integrations that declare a components
|
|
64
64
|
* root; each becomes a scannable package keyed by its docsDir. `configured`
|
|
65
|
-
* reports whether
|
|
66
|
-
*
|
|
67
|
-
* nothing
|
|
65
|
+
* reports whether the project configured ANY integration — including one whose
|
|
66
|
+
* manifest failed to load, which contributes nothing but is emphatically not
|
|
67
|
+
* "nothing configured". It lets an empty result distinguish "nothing
|
|
68
|
+
* configured" (`false`) from "configured but nothing discovered" (`true`),
|
|
69
|
+
* which the list leaf surfaces as `meta`.
|
|
68
70
|
*
|
|
69
71
|
* @returns {Promise<{packages: ScannedPackage[], configured: boolean}>}
|
|
70
72
|
*/
|
|
@@ -84,7 +86,7 @@ export async function discoverPackages() {
|
|
|
84
86
|
docsDir: integration.components,
|
|
85
87
|
}));
|
|
86
88
|
if (explicitPackages.length === 0) {
|
|
87
|
-
return {packages: [], configured:
|
|
89
|
+
return {packages: [], configured: loadedIntegrations.length > 0};
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
const packages = scanAllPackages(
|
|
@@ -41,6 +41,22 @@ export type ThemeBuildCheckResponse = {
|
|
|
41
41
|
checked: string[];
|
|
42
42
|
};
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* xds --json theme build <a> <b> … — several themes in one invocation. Each
|
|
46
|
+
* result carries the file as it was passed and the receipt a single-file build
|
|
47
|
+
* would have returned (null when that theme produced no CSS). One file still
|
|
48
|
+
* returns the bare theme.build / theme.build.check envelope.
|
|
49
|
+
*/
|
|
50
|
+
export type ThemeBuildBatchResponse = {
|
|
51
|
+
type: "theme.build.batch";
|
|
52
|
+
data: {
|
|
53
|
+
count: number;
|
|
54
|
+
results: Array<{
|
|
55
|
+
file: string;
|
|
56
|
+
receipt: ThemeBuildResponse | ThemeBuildCheckResponse | null;
|
|
57
|
+
}>;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
44
60
|
/**
|
|
45
61
|
* A single theme entry as surfaced by `theme list`.
|
|
46
62
|
*/
|
package/api/theme/theme.type.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* ------------------------------------------------------------------
|
|
11
11
|
* xds --json theme build <file> -> theme.build
|
|
12
12
|
* xds --json theme build <file> --check -> theme.build.check
|
|
13
|
+
* xds --json theme build <a> <b> … -> theme.build.batch
|
|
13
14
|
* xds --json theme list -> theme.list
|
|
14
15
|
* xds --json theme add <slug> -> theme.add
|
|
15
16
|
* xds --json theme template -> theme.template
|
|
@@ -35,6 +36,16 @@
|
|
|
35
36
|
* @property {{name: string, upToDate: boolean, stale: Array<{path: string, reason: 'missing' | 'outdated'}>, checked: string[]}} data
|
|
36
37
|
*/
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* xds --json theme build <a> <b> … — several themes in one invocation. Each
|
|
41
|
+
* result carries the file as it was passed and the receipt a single-file build
|
|
42
|
+
* would have returned (null when that theme produced no CSS). One file still
|
|
43
|
+
* returns the bare theme.build / theme.build.check envelope.
|
|
44
|
+
* @typedef {object} ThemeBuildBatchResponse
|
|
45
|
+
* @property {'theme.build.batch'} type
|
|
46
|
+
* @property {{count: number, results: Array<{file: string, receipt: ThemeBuildResponse | ThemeBuildCheckResponse | null}>}} data
|
|
47
|
+
*/
|
|
48
|
+
|
|
38
49
|
/**
|
|
39
50
|
* A single theme entry as surfaced by `theme list`.
|
|
40
51
|
* @typedef {object} ThemeListEntry
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
* Usage:
|
|
18
18
|
* astryx theme build ./src/themes/ocean.ts
|
|
19
19
|
* astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css
|
|
20
|
+
* astryx theme build ./src/themes/*.ts
|
|
21
|
+
*
|
|
22
|
+
* `build` takes one or more theme files. Each is compiled by the same
|
|
23
|
+
* single-file API call, in argument order, in one process — so the outputs are
|
|
24
|
+
* byte-identical to running the CLI once per theme, and the first failure stops
|
|
25
|
+
* the run exactly as a shell loop under `set -e` would.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
import * as fs from 'node:fs';
|
|
@@ -80,66 +86,80 @@ function runThemeBuildOnceChild(file, options) {
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/**
|
|
83
|
-
* Watch
|
|
84
|
-
* rebuilds (debounced)
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
89
|
+
* Watch theme files and rebuild on change. Runs an initial build of each, then
|
|
90
|
+
* rebuilds (debounced) the file that changed, until interrupted with Ctrl-C.
|
|
91
|
+
* Rebuilds are serialized: one at a time, in the order the changes arrived, so
|
|
92
|
+
* the log stays readable. Each rebuild runs in a child process so a build error
|
|
93
|
+
* (which the single-build path reports via a hard exit) is contained and the
|
|
94
|
+
* watcher keeps running.
|
|
88
95
|
*
|
|
89
|
-
* @param {string}
|
|
90
|
-
*
|
|
96
|
+
* @param {Array<{file: string, filePath: string}>} entries - The theme file
|
|
97
|
+
* arguments as the user passed them, with their resolved absolute paths.
|
|
91
98
|
* @param {{out?: string, iconsSpecifier?: string}} options - Parsed command options.
|
|
92
99
|
* @returns {Promise<void>} Resolves when the watcher is stopped (Ctrl-C).
|
|
93
100
|
*/
|
|
94
|
-
async function runThemeBuildWatch(
|
|
95
|
-
const rel =
|
|
101
|
+
async function runThemeBuildWatch(entries, options) {
|
|
102
|
+
const rel = (/** @type {string} */ filePath) =>
|
|
103
|
+
path.relative(process.cwd(), filePath);
|
|
104
|
+
const watchingLine = `\nWatching ${entries
|
|
105
|
+
.map(e => rel(e.filePath))
|
|
106
|
+
.join(', ')} for changes — press Ctrl-C to stop.`;
|
|
96
107
|
|
|
97
108
|
// Initial build.
|
|
98
|
-
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
await runThemeBuildOnceChild(entry.file, options);
|
|
111
|
+
}
|
|
99
112
|
|
|
100
113
|
let building = false;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
/** @type {Set<{file: string, filePath: string}>} */
|
|
115
|
+
const queued = new Set();
|
|
116
|
+
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
|
117
|
+
const debounces = new Map();
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
/** @param {{file: string, filePath: string}} entry */
|
|
120
|
+
const rebuild = async entry => {
|
|
106
121
|
if (building) {
|
|
107
122
|
// Coalesce changes that land mid-build into a single follow-up run.
|
|
108
|
-
queued
|
|
123
|
+
queued.add(entry);
|
|
109
124
|
return;
|
|
110
125
|
}
|
|
111
126
|
building = true;
|
|
112
|
-
emit(text(`\nChange detected — rebuilding ${rel}...`));
|
|
113
|
-
await runThemeBuildOnceChild(file, options);
|
|
127
|
+
emit(text(`\nChange detected — rebuilding ${rel(entry.filePath)}...`));
|
|
128
|
+
await runThemeBuildOnceChild(entry.file, options);
|
|
114
129
|
building = false;
|
|
115
|
-
emit(text(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
emit(text(watchingLine));
|
|
131
|
+
const next = queued.values().next();
|
|
132
|
+
if (!next.done) {
|
|
133
|
+
queued.delete(next.value);
|
|
134
|
+
rebuild(next.value);
|
|
119
135
|
}
|
|
120
136
|
};
|
|
121
137
|
|
|
122
138
|
// Some editors replace the file (rename) rather than writing in place, which
|
|
123
139
|
// can drop the watch. Watch the containing directory and filter to our file
|
|
124
140
|
// so edits survive atomic-save/rename.
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
const watchers = entries.map(entry => {
|
|
142
|
+
const baseName = path.basename(entry.filePath);
|
|
143
|
+
return fs.watch(path.dirname(entry.filePath), (_eventType, changed) => {
|
|
144
|
+
if (changed && changed !== baseName) return;
|
|
145
|
+
clearTimeout(debounces.get(entry.filePath));
|
|
146
|
+
// Debounce: editors often emit several events per save.
|
|
147
|
+
debounces.set(
|
|
148
|
+
entry.filePath,
|
|
149
|
+
setTimeout(() => rebuild(entry), 100),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
132
152
|
});
|
|
133
153
|
|
|
134
154
|
// Announce readiness only AFTER fs.watch is armed — the log is the "safe to
|
|
135
155
|
// edit" signal (tests and humans rely on it), so printing it before the watch
|
|
136
156
|
// is registered would race: a change in that gap is silently missed.
|
|
137
|
-
emit(text(
|
|
157
|
+
emit(text(watchingLine));
|
|
138
158
|
|
|
139
159
|
await new Promise((/** @type {(value?: void) => void} */ resolve) => {
|
|
140
160
|
const stop = () => {
|
|
141
|
-
clearTimeout(
|
|
142
|
-
|
|
161
|
+
for (const d of debounces.values()) clearTimeout(d);
|
|
162
|
+
for (const w of watchers) w.close();
|
|
143
163
|
emit(text('\nStopped watching.'));
|
|
144
164
|
resolve();
|
|
145
165
|
};
|
|
@@ -208,15 +228,31 @@ export function registerTheme(program) {
|
|
|
208
228
|
defineCommand(theme, themeBuildCommand, {
|
|
209
229
|
fn: themeBuildFn,
|
|
210
230
|
action: async (
|
|
211
|
-
/** @type {string} */
|
|
231
|
+
/** @type {string[]} */ files,
|
|
212
232
|
/** @type {{out?: string, watch?: boolean, check?: boolean, iconsSpecifier?: string}} */ options,
|
|
213
233
|
) => {
|
|
214
|
-
const filePath = path.resolve(process.cwd(), file);
|
|
215
234
|
const json = program.opts().json || false;
|
|
235
|
+
const entries = files.map(file => ({
|
|
236
|
+
file,
|
|
237
|
+
filePath: path.resolve(process.cwd(), file),
|
|
238
|
+
}));
|
|
216
239
|
|
|
217
|
-
|
|
218
|
-
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (fs.existsSync(entry.filePath)) continue;
|
|
242
|
+
// A quoted glob reaches us unexpanded: say so rather than reporting a
|
|
243
|
+
// literal `themes/*.ts` as a missing file.
|
|
244
|
+
const looksGlobby = /[*?[\]{}]/.test(entry.file);
|
|
245
|
+
cliError(`File not found: ${entry.filePath}`, {
|
|
219
246
|
code: ERROR_CODES.ERR_FILE_NOT_FOUND,
|
|
247
|
+
suggestions: looksGlobby
|
|
248
|
+
? [
|
|
249
|
+
{
|
|
250
|
+
name: `astryx theme build ${entry.file.replace(/['"]/g, '')}`,
|
|
251
|
+
reason:
|
|
252
|
+
'globs are expanded by your shell — pass the pattern unquoted, or list the files',
|
|
253
|
+
},
|
|
254
|
+
]
|
|
255
|
+
: undefined,
|
|
220
256
|
});
|
|
221
257
|
return;
|
|
222
258
|
}
|
|
@@ -230,6 +266,19 @@ export function registerTheme(program) {
|
|
|
230
266
|
return;
|
|
231
267
|
}
|
|
232
268
|
|
|
269
|
+
// --out names one output file, so it cannot describe N themes. Without
|
|
270
|
+
// it each theme writes `<theme name>.css` beside its source, which is
|
|
271
|
+
// what a multi-theme build wants anyway.
|
|
272
|
+
if (options.out && entries.length > 1) {
|
|
273
|
+
cliError(
|
|
274
|
+
`--out takes a single output path and ${entries.length} theme files were given. ` +
|
|
275
|
+
'Build them without --out (each theme writes <name>.css next to its source), ' +
|
|
276
|
+
'or run one invocation per theme.',
|
|
277
|
+
{code: ERROR_CODES.ERR_THEME_INVALID},
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
233
282
|
// Watch mode: run an initial build, then rebuild on every change to the
|
|
234
283
|
// theme file. Watch is a human-interactive, long-running mode — it is not
|
|
235
284
|
// supported in --json (machine) mode, which expects a single envelope.
|
|
@@ -240,41 +289,81 @@ export function registerTheme(program) {
|
|
|
240
289
|
});
|
|
241
290
|
return;
|
|
242
291
|
}
|
|
243
|
-
await runThemeBuildWatch(
|
|
292
|
+
await runThemeBuildWatch(entries, options);
|
|
244
293
|
return;
|
|
245
294
|
}
|
|
246
295
|
|
|
247
|
-
// Non-watch: delegate to the API compiler
|
|
248
|
-
// --json mode (log → stdout via
|
|
249
|
-
// "Building theme from" line, the
|
|
250
|
-
// instructions are all emitted from
|
|
296
|
+
// Non-watch: delegate to the API compiler, once per theme, in argument
|
|
297
|
+
// order. Enable human output unless in --json mode (log → stdout via
|
|
298
|
+
// humanLog, warn/error → stderr). The "Building theme from" line, the
|
|
299
|
+
// ✓/warning lines, and the install instructions are all emitted from
|
|
300
|
+
// inside themeBuild via the shared logger.
|
|
251
301
|
logger.setSilent(json);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
302
|
+
/** @type {Array<{file: string, receipt: import('../../../api/theme/theme.type.mjs').ThemeBuildResponse | import('../../../api/theme/theme.type.mjs').ThemeBuildCheckResponse | null}>} */
|
|
303
|
+
const results = [];
|
|
304
|
+
let stale = false;
|
|
305
|
+
for (const entry of entries) {
|
|
306
|
+
try {
|
|
307
|
+
const result = await themeBuild(
|
|
308
|
+
entry.file,
|
|
309
|
+
{
|
|
310
|
+
out: options.out,
|
|
311
|
+
check: options.check,
|
|
312
|
+
iconsSpecifier: options.iconsSpecifier,
|
|
313
|
+
},
|
|
314
|
+
{cwd: process.cwd()},
|
|
315
|
+
);
|
|
316
|
+
results.push({file: entry.file, receipt: result ?? null});
|
|
317
|
+
if (
|
|
318
|
+
options.check &&
|
|
319
|
+
result &&
|
|
320
|
+
result.type === 'theme.build.check' &&
|
|
321
|
+
!result.data.upToDate
|
|
322
|
+
) {
|
|
323
|
+
stale = true;
|
|
324
|
+
}
|
|
325
|
+
} catch (e) {
|
|
326
|
+
const err =
|
|
327
|
+
/** @type {import('../../../api/error.mjs').AstryxError} */ (e);
|
|
328
|
+
// Stop at the first failure, as a shell loop under `set -e` does.
|
|
329
|
+
// With several themes in flight the message alone rarely says which
|
|
330
|
+
// one broke, so name it.
|
|
331
|
+
cliError(
|
|
332
|
+
entries.length > 1 ? `${entry.file}: ${err.message}` : err.message,
|
|
333
|
+
{suggestions: err.suggestions, code: err.code},
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
273
336
|
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (json) {
|
|
340
|
+
// One theme keeps the single-envelope contract it has always had; a
|
|
341
|
+
// batch gets its own discriminant rather than N envelopes on stdout.
|
|
342
|
+
if (entries.length === 1) {
|
|
343
|
+
if (results[0].receipt) jsonOut(results[0].receipt);
|
|
344
|
+
} else {
|
|
345
|
+
/** @type {import('../../../api/theme/theme.type.mjs').ThemeBuildBatchResponse} */
|
|
346
|
+
const batch = {
|
|
347
|
+
type: 'theme.build.batch',
|
|
348
|
+
data: {count: results.length, results},
|
|
349
|
+
};
|
|
350
|
+
jsonOut(batch);
|
|
351
|
+
}
|
|
352
|
+
} else if (entries.length > 1) {
|
|
353
|
+
emit(
|
|
354
|
+
text(
|
|
355
|
+
options.check
|
|
356
|
+
? `\n${stale ? '✗' : '✓'} Checked ${entries.length} themes.`
|
|
357
|
+
: `\n✓ Built ${entries.length} themes.`,
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// In --check mode a stale/missing output is a failure: exit non-zero
|
|
363
|
+
// (after emitting the receipt) so CI can gate on it. The receipt is
|
|
364
|
+
// already printed above (shared logger or --json envelope).
|
|
365
|
+
if (options.check && stale) {
|
|
366
|
+
process.exitCode = 1;
|
|
278
367
|
}
|
|
279
368
|
},
|
|
280
369
|
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Tests for `astryx theme build <a> <b> …` — several themes per
|
|
5
|
+
* invocation (kt-lc9s).
|
|
6
|
+
*
|
|
7
|
+
* The load-bearing guarantee is equivalence: one invocation over N theme files
|
|
8
|
+
* must write exactly the bytes N serial invocations write. Everything else here
|
|
9
|
+
* guards the edges that only exist once the argument is variadic — the JSON
|
|
10
|
+
* envelope, --check across a set, --out (which names one file), and fail-fast.
|
|
11
|
+
*
|
|
12
|
+
* `astryx theme build` needs a compiled @astryxdesign/core, so this suite
|
|
13
|
+
* builds core once via the shared ensureCoreBuilt() helper.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import * as os from 'node:os';
|
|
20
|
+
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
21
|
+
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
22
|
+
|
|
23
|
+
const THEMES = {
|
|
24
|
+
'alpha.mjs': `export default { name: 'alpha', tokens: { '--color-bg': '#ffffff' } };\n`,
|
|
25
|
+
'beta.mjs': `export default { name: 'beta', tokens: { '--color-bg': '#010203' } };\n`,
|
|
26
|
+
'gamma.mjs': `export default { name: 'gamma', tokens: { '--color-bg': '#ff00ff' } };\n`,
|
|
27
|
+
};
|
|
28
|
+
const FILES = Object.keys(THEMES);
|
|
29
|
+
const OUTPUTS = ['alpha', 'beta', 'gamma'].flatMap(n => [
|
|
30
|
+
`${n}.css`,
|
|
31
|
+
`${n}.js`,
|
|
32
|
+
`${n}.d.ts`,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** A fresh temp dir holding the three theme sources. */
|
|
36
|
+
function themeDir() {
|
|
37
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-theme-multi-'));
|
|
38
|
+
for (const [name, source] of Object.entries(THEMES)) {
|
|
39
|
+
fs.writeFileSync(path.join(dir, name), source);
|
|
40
|
+
}
|
|
41
|
+
dirs.push(dir);
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @type {string[]} */
|
|
46
|
+
let dirs;
|
|
47
|
+
|
|
48
|
+
beforeAll(() => {
|
|
49
|
+
ensureCoreBuilt();
|
|
50
|
+
}, 200_000);
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
dirs = [];
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
for (const dir of dirs) fs.rmSync(dir, {recursive: true, force: true});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('theme build with several files', () => {
|
|
60
|
+
it('writes byte-identical output to one invocation per theme', async () => {
|
|
61
|
+
const serial = themeDir();
|
|
62
|
+
for (const file of FILES) {
|
|
63
|
+
const r = await runCli(['theme', 'build', file], serial);
|
|
64
|
+
expect(r.status).toBe(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const batch = themeDir();
|
|
68
|
+
const r = await runCli(['theme', 'build', ...FILES], batch);
|
|
69
|
+
expect(r.status).toBe(0);
|
|
70
|
+
|
|
71
|
+
for (const output of OUTPUTS) {
|
|
72
|
+
const one = fs.readFileSync(path.join(serial, output), 'utf8');
|
|
73
|
+
const many = fs.readFileSync(path.join(batch, output), 'utf8');
|
|
74
|
+
expect({output, content: many}).toEqual({output, content: one});
|
|
75
|
+
}
|
|
76
|
+
}, 120_000);
|
|
77
|
+
|
|
78
|
+
it('reports every theme in one theme.build.batch envelope', async () => {
|
|
79
|
+
const dir = themeDir();
|
|
80
|
+
const r = await runCli(['--json', 'theme', 'build', ...FILES], dir);
|
|
81
|
+
expect(r.status).toBe(0);
|
|
82
|
+
|
|
83
|
+
const envelope = JSON.parse(r.stdout);
|
|
84
|
+
expect(envelope.type).toBe('theme.build.batch');
|
|
85
|
+
expect(envelope.data.count).toBe(3);
|
|
86
|
+
expect(envelope.data.results.map(x => x.file)).toEqual(FILES);
|
|
87
|
+
expect(envelope.data.results.map(x => x.receipt.data.name)).toEqual([
|
|
88
|
+
'alpha',
|
|
89
|
+
'beta',
|
|
90
|
+
'gamma',
|
|
91
|
+
]);
|
|
92
|
+
expect(envelope.data.results[0].receipt.type).toBe('theme.build');
|
|
93
|
+
}, 120_000);
|
|
94
|
+
|
|
95
|
+
it('keeps the bare theme.build envelope for a single file', async () => {
|
|
96
|
+
const dir = themeDir();
|
|
97
|
+
const r = await runCli(['--json', 'theme', 'build', 'alpha.mjs'], dir);
|
|
98
|
+
expect(r.status).toBe(0);
|
|
99
|
+
expect(JSON.parse(r.stdout).type).toBe('theme.build');
|
|
100
|
+
}, 120_000);
|
|
101
|
+
|
|
102
|
+
it('--check passes when every theme is current and fails when one drifts', async () => {
|
|
103
|
+
const dir = themeDir();
|
|
104
|
+
expect((await runCli(['theme', 'build', ...FILES], dir)).status).toBe(0);
|
|
105
|
+
|
|
106
|
+
const fresh = await runCli(['theme', 'build', ...FILES, '--check'], dir);
|
|
107
|
+
expect(fresh.status).toBe(0);
|
|
108
|
+
|
|
109
|
+
// Drift one committed output rather than its source: the harness runs the
|
|
110
|
+
// CLI in-process, where jiti would serve a re-read theme file from its
|
|
111
|
+
// module cache.
|
|
112
|
+
fs.writeFileSync(path.join(dir, 'beta.css'), '/* hand-edited */\n');
|
|
113
|
+
const drifted = await runCli(['theme', 'build', ...FILES, '--check'], dir);
|
|
114
|
+
expect(drifted.status).toBe(1);
|
|
115
|
+
expect(drifted.stdout + drifted.stderr).toMatch(/beta\.css/);
|
|
116
|
+
}, 120_000);
|
|
117
|
+
|
|
118
|
+
it('rejects --out with more than one theme', async () => {
|
|
119
|
+
const dir = themeDir();
|
|
120
|
+
const r = await runCli(
|
|
121
|
+
['theme', 'build', 'alpha.mjs', 'beta.mjs', '--out', 'one.css'],
|
|
122
|
+
dir,
|
|
123
|
+
);
|
|
124
|
+
expect(r.status).toBe(1);
|
|
125
|
+
expect(r.stderr).toMatch(/--out takes a single output path/);
|
|
126
|
+
expect(fs.existsSync(path.join(dir, 'one.css'))).toBe(false);
|
|
127
|
+
}, 120_000);
|
|
128
|
+
|
|
129
|
+
it('stops at the first failure and names the theme that failed', async () => {
|
|
130
|
+
const dir = themeDir();
|
|
131
|
+
fs.writeFileSync(
|
|
132
|
+
path.join(dir, 'beta.mjs'),
|
|
133
|
+
`export default { tokens: { '--color-bg': '#010203' } };\n`,
|
|
134
|
+
);
|
|
135
|
+
const r = await runCli(['theme', 'build', ...FILES], dir);
|
|
136
|
+
expect(r.status).toBe(1);
|
|
137
|
+
expect(r.stderr).toMatch(/beta\.mjs: Theme must have a name/);
|
|
138
|
+
expect(fs.existsSync(path.join(dir, 'alpha.css'))).toBe(true);
|
|
139
|
+
expect(fs.existsSync(path.join(dir, 'gamma.css'))).toBe(false);
|
|
140
|
+
}, 120_000);
|
|
141
|
+
|
|
142
|
+
it('tells the user a quoted glob was never expanded', async () => {
|
|
143
|
+
const dir = themeDir();
|
|
144
|
+
const r = await runCli(['theme', 'build', '*.mjs'], dir);
|
|
145
|
+
expect(r.status).toBe(1);
|
|
146
|
+
expect(r.stderr).toMatch(/expanded by your shell/);
|
|
147
|
+
}, 120_000);
|
|
148
|
+
});
|
|
@@ -156,4 +156,71 @@ describe('theme build --watch', () => {
|
|
|
156
156
|
expect(exited).toBe(true);
|
|
157
157
|
expect(stdout).toMatch(/Stopped watching/);
|
|
158
158
|
}, 30_000);
|
|
159
|
+
|
|
160
|
+
it('watches every file it was given and rebuilds only the one that changed', async () => {
|
|
161
|
+
const first = path.join(tmpDir, 'w1.mjs');
|
|
162
|
+
const second = path.join(tmpDir, 'w2.mjs');
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
first,
|
|
165
|
+
`export default { name: 'w1', tokens: { '--color-bg': '#ffffff' } };\n`,
|
|
166
|
+
);
|
|
167
|
+
fs.writeFileSync(
|
|
168
|
+
second,
|
|
169
|
+
`export default { name: 'w2', tokens: { '--color-bg': '#eeeeee' } };\n`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const child = spawn(
|
|
173
|
+
process.execPath,
|
|
174
|
+
[CLI_BIN, 'theme', 'build', 'w1.mjs', 'w2.mjs', '--watch'],
|
|
175
|
+
{cwd: tmpDir, env: {...process.env, FORCE_COLOR: '0'}},
|
|
176
|
+
);
|
|
177
|
+
let stdout = '';
|
|
178
|
+
child.stdout.on('data', d => (stdout += d.toString()));
|
|
179
|
+
child.stderr.on('data', d => (stdout += d.toString()));
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const built = await waitFor(
|
|
183
|
+
() =>
|
|
184
|
+
fs.existsSync(path.join(tmpDir, 'w1.css')) &&
|
|
185
|
+
fs.existsSync(path.join(tmpDir, 'w2.css')),
|
|
186
|
+
);
|
|
187
|
+
expect(built).toBe(true);
|
|
188
|
+
await waitFor(() => /Watching w1\.mjs, w2\.mjs/.test(stdout));
|
|
189
|
+
const firstCssBefore = fs.readFileSync(path.join(tmpDir, 'w1.css'), 'utf-8');
|
|
190
|
+
|
|
191
|
+
const changed = `export default { name: 'w2', tokens: { '--color-bg': '#010203' } };\n`;
|
|
192
|
+
fs.writeFileSync(second, changed);
|
|
193
|
+
|
|
194
|
+
const rebuilt = await waitFor(
|
|
195
|
+
() => {
|
|
196
|
+
try {
|
|
197
|
+
if (
|
|
198
|
+
fs
|
|
199
|
+
.readFileSync(path.join(tmpDir, 'w2.css'), 'utf-8')
|
|
200
|
+
.includes('#010203')
|
|
201
|
+
) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// CSS mid-write; fall through to re-touch.
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
fs.writeFileSync(second, changed);
|
|
209
|
+
} catch {
|
|
210
|
+
// Re-touch failed (e.g. dir mid-teardown); the next poll retries.
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
},
|
|
214
|
+
{timeout: 20000, interval: 200},
|
|
215
|
+
);
|
|
216
|
+
expect(rebuilt).toBe(true);
|
|
217
|
+
expect(stdout).toMatch(/rebuilding w2\.mjs/);
|
|
218
|
+
expect(stdout).not.toMatch(/rebuilding w1\.mjs/);
|
|
219
|
+
expect(fs.readFileSync(path.join(tmpDir, 'w1.css'), 'utf-8')).toBe(
|
|
220
|
+
firstCssBefore,
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
child.kill('SIGINT');
|
|
224
|
+
}
|
|
225
|
+
}, 30_000);
|
|
159
226
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx discover` and `astryx search` against an integration whose
|
|
5
|
+
* manifest fails to load.
|
|
6
|
+
*
|
|
7
|
+
* A manifest authored against a removed API throws on import, contributes
|
|
8
|
+
* nothing, and used to leave discover reporting "No integrations configured."
|
|
9
|
+
* — the package simply vanished. These drive the real CLI in-process against a
|
|
10
|
+
* hermetic project (astryx.config.mjs + a throwing astryx.integration.mjs under
|
|
11
|
+
* node_modules) and pin the loud behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import {fileURLToPath} from 'node:url';
|
|
19
|
+
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
20
|
+
|
|
21
|
+
const CORE_DIR = path.resolve(
|
|
22
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
23
|
+
'../../../../core',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
let tmpDir;
|
|
27
|
+
let project;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configure `@test/broken` with a manifest that throws at import. Calling an
|
|
31
|
+
* undeclared factory stands in for the real failure this came from: a 0.2.x
|
|
32
|
+
* manifest still calling `createIntegration`, which 0.3.0 removed.
|
|
33
|
+
*/
|
|
34
|
+
function buildBrokenIntegration() {
|
|
35
|
+
const intDir = path.join(project, 'node_modules', '@test', 'broken');
|
|
36
|
+
fs.mkdirSync(path.join(intDir, 'components'), {recursive: true});
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(intDir, 'package.json'),
|
|
39
|
+
JSON.stringify({name: '@test/broken', version: '1.2.3'}),
|
|
40
|
+
);
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
path.join(intDir, 'astryx.integration.mjs'),
|
|
43
|
+
`export default createIntegration({components: './components'});\n`,
|
|
44
|
+
);
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(project, 'astryx.config.mjs'),
|
|
47
|
+
`export default {integrations: ['@test/broken']};\n`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-discover-broken-'));
|
|
53
|
+
project = path.join(tmpDir, 'project');
|
|
54
|
+
fs.mkdirSync(project, {recursive: true});
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(project, 'package.json'),
|
|
57
|
+
JSON.stringify({name: 'proj', version: '1.0.0'}),
|
|
58
|
+
);
|
|
59
|
+
buildBrokenIntegration();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('astryx discover with a manifest that fails to load', () => {
|
|
67
|
+
it('warns on stderr and does not claim nothing is configured', async () => {
|
|
68
|
+
const {status, stdout, stderr} = await runCli(['discover'], {cwd: project});
|
|
69
|
+
|
|
70
|
+
expect(status).toBe(0);
|
|
71
|
+
expect(stderr).toContain(
|
|
72
|
+
'Warning: @test/broken has 1 integration issue(s). ' +
|
|
73
|
+
'Run: astryx validate-integration @test/broken',
|
|
74
|
+
);
|
|
75
|
+
expect(stdout).not.toContain('No integrations configured.');
|
|
76
|
+
expect(stdout).toContain('No external components found in configured integrations.');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reports meta.configured=true in --json, with the nudge suppressed', async () => {
|
|
80
|
+
const {status, stdout, stderr} = await runCli(['discover', '--json'], {
|
|
81
|
+
cwd: project,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(status).toBe(0);
|
|
85
|
+
expect(JSON.parse(stdout).meta).toEqual({configured: true});
|
|
86
|
+
expect(stderr).not.toContain('integration issue');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('astryx search with a manifest that fails to load', () => {
|
|
91
|
+
// search needs a resolvable @astryxdesign/core; without one it errors out
|
|
92
|
+
// before it can list anything, which is not the case under test.
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
const scope = path.join(project, 'node_modules', '@astryxdesign');
|
|
95
|
+
fs.mkdirSync(scope, {recursive: true});
|
|
96
|
+
fs.symlinkSync(CORE_DIR, path.join(scope, 'core'), 'dir');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('warns on stderr, and suppresses the nudge under --json', async () => {
|
|
100
|
+
const {status, stderr} = await runCli(['search', 'button'], {cwd: project});
|
|
101
|
+
|
|
102
|
+
expect(status).toBe(0);
|
|
103
|
+
expect(stderr).toContain(
|
|
104
|
+
'Warning: @test/broken has 1 integration issue(s). ' +
|
|
105
|
+
'Run: astryx validate-integration @test/broken',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const asJson = await runCli(['search', 'button', '--json'], {cwd: project});
|
|
109
|
+
expect(asJson.status).toBe(0);
|
|
110
|
+
expect(asJson.stderr).not.toContain('integration issue');
|
|
111
|
+
}, 30_000);
|
|
112
|
+
});
|
|
@@ -15,6 +15,8 @@ import {jsonOut} from '../../../foundation/response/json.mjs';
|
|
|
15
15
|
import {emit, section, text, record, records, list, code} from '../formatters/index.mjs';
|
|
16
16
|
import {cliError} from '../lib/cli-error.mjs';
|
|
17
17
|
import {discover as discoverApi} from '../../../api/discover/discover.mjs';
|
|
18
|
+
import {Project} from '../../../foundation/config/project.mjs';
|
|
19
|
+
import {warnOnIntegrationIssues} from '../../../foundation/integrations/integration-warnings.mjs';
|
|
18
20
|
import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
|
|
19
21
|
import {defineCommand} from '../lib/define-command.mjs';
|
|
20
22
|
import {doc as discoverCommand} from './discover.doc.mjs';
|
|
@@ -41,6 +43,16 @@ export function registerDiscover(program) {
|
|
|
41
43
|
const zh = program.opts().zh || false;
|
|
42
44
|
const run = getCliInvocation();
|
|
43
45
|
|
|
46
|
+
// Non-blocking nudge: if any configured integration has validation
|
|
47
|
+
// issues, print one compact line to stderr pointing at
|
|
48
|
+
// validate-integration. Best-effort; suppressed in --json mode.
|
|
49
|
+
try {
|
|
50
|
+
const project = await Project.load(process.cwd());
|
|
51
|
+
await warnOnIntegrationIssues(project.loadedIntegrations, {json});
|
|
52
|
+
} catch {
|
|
53
|
+
// Never let the nudge break the command.
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
let result;
|
|
45
57
|
try {
|
|
46
58
|
result = await discoverApi(query, {components: options.components, lang, zh});
|
|
@@ -26,6 +26,8 @@ import {emit, section, text, records} from '../formatters/index.mjs';
|
|
|
26
26
|
import {cliError} from '../lib/cli-error.mjs';
|
|
27
27
|
import {defineCommand} from '../lib/define-command.mjs';
|
|
28
28
|
import {search as searchApi} from '../../../api/search/search.mjs';
|
|
29
|
+
import {Project} from '../../../foundation/config/project.mjs';
|
|
30
|
+
import {warnOnIntegrationIssues} from '../../../foundation/integrations/integration-warnings.mjs';
|
|
29
31
|
import {doc as searchCommand} from './search.doc.mjs';
|
|
30
32
|
import {doc as searchFn} from '../../../api/search/search.doc.mjs';
|
|
31
33
|
|
|
@@ -41,6 +43,13 @@ export function registerSearch(program) {
|
|
|
41
43
|
) => {
|
|
42
44
|
const json = program.opts().json || false;
|
|
43
45
|
|
|
46
|
+
try {
|
|
47
|
+
const project = await Project.load(process.cwd());
|
|
48
|
+
await warnOnIntegrationIssues(project.loadedIntegrations, {json});
|
|
49
|
+
} catch {
|
|
50
|
+
// Never let the nudge break the command.
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
// Parse --limit to a number; the API validates it (positive integer) and
|
|
45
54
|
// throws ERR_INVALID_ARGUMENT, so we pass NaN through rather than
|
|
46
55
|
// pre-rejecting with a generic code here.
|
|
@@ -14,17 +14,19 @@ export const doc = {
|
|
|
14
14
|
name: 'theme build',
|
|
15
15
|
displayName: 'astryx theme build',
|
|
16
16
|
namespace: 'cli',
|
|
17
|
-
summary: 'Compile
|
|
17
|
+
summary: 'Compile one or more defineTheme files to CSS + JS',
|
|
18
18
|
description:
|
|
19
19
|
'Compiles a file that calls defineTheme() into a scoped CSS file, a JS module, and ' +
|
|
20
|
-
'type declarations: the exact CSS the <Theme> runtime emits.
|
|
20
|
+
'type declarations: the exact CSS the <Theme> runtime emits. Takes any number of theme ' +
|
|
21
|
+
'files and compiles them in one process, in argument order, stopping at the first ' +
|
|
22
|
+
'failure — an app with several themes does not need a shell loop. With --check it writes ' +
|
|
21
23
|
'nothing and instead reports whether the committed outputs have drifted from source. ' +
|
|
22
24
|
'When a separate build step emits the icon registry, --icons-specifier declares the ' +
|
|
23
25
|
'fully specified module path that the generated JS should import.',
|
|
24
26
|
fn: 'themeBuild',
|
|
25
|
-
args: [{name: '
|
|
27
|
+
args: [{name: 'files', param: 'file', required: true, variadic: true}],
|
|
26
28
|
options: [
|
|
27
|
-
{flag: '-o, --out <path>', param: 'options.out', description: 'Output CSS file path'},
|
|
29
|
+
{flag: '-o, --out <path>', param: 'options.out', description: 'Output CSS file path (single theme only)'},
|
|
28
30
|
{
|
|
29
31
|
flag: '--icons-specifier <specifier>',
|
|
30
32
|
param: 'options.iconsSpecifier',
|
|
@@ -33,7 +35,7 @@ export const doc = {
|
|
|
33
35
|
},
|
|
34
36
|
{
|
|
35
37
|
flag: '-w, --watch',
|
|
36
|
-
description: 'Rebuild automatically when
|
|
38
|
+
description: 'Rebuild automatically when a theme file changes (Ctrl-C to stop)',
|
|
37
39
|
},
|
|
38
40
|
{
|
|
39
41
|
flag: '-c, --check',
|
|
@@ -47,6 +49,10 @@ export const doc = {
|
|
|
47
49
|
label: 'Build to a CSS file',
|
|
48
50
|
cli: 'astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css',
|
|
49
51
|
},
|
|
52
|
+
{
|
|
53
|
+
label: 'Build every theme in a directory',
|
|
54
|
+
cli: 'astryx theme build ./src/themes/*.ts',
|
|
55
|
+
},
|
|
50
56
|
{
|
|
51
57
|
label: 'Check for drift (CI)',
|
|
52
58
|
cli: 'astryx theme build ./src/themes/ocean.ts --check',
|
|
@@ -61,7 +61,7 @@ describe('--json shim: --help renders JSON envelope', () => {
|
|
|
61
61
|
expect(parsed.apiVersion).toBe(1);
|
|
62
62
|
expect(parsed.type).toBe('help');
|
|
63
63
|
expect(parsed.data.command).toBe('astryx theme build');
|
|
64
|
-
expect(parsed.data.usage).toMatch(/<
|
|
64
|
+
expect(parsed.data.usage).toMatch(/<files\.\.\.>/);
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
@@ -73,7 +73,7 @@ export const RESPONSE_TYPES = {
|
|
|
73
73
|
'template.cdn',
|
|
74
74
|
],
|
|
75
75
|
hook: ['hook.list', 'hook.detail', 'hook.detail.params'],
|
|
76
|
-
'theme build': ['theme.build', 'theme.build.check'],
|
|
76
|
+
'theme build': ['theme.build', 'theme.build.check', 'theme.build.batch'],
|
|
77
77
|
'theme list': ['theme.list'],
|
|
78
78
|
'theme add': ['theme.list', 'theme.add'],
|
|
79
79
|
'theme template': ['theme.template'],
|
|
@@ -146,8 +146,10 @@ describe('manifest: shape', () => {
|
|
|
146
146
|
const component = allEntries.find((c) => c.name === 'component');
|
|
147
147
|
expect(component.arguments.map((a) => a.name)).toContain('name');
|
|
148
148
|
const themeBuild = allEntries.find((c) => c.name === 'theme build');
|
|
149
|
-
expect(themeBuild.arguments.map((a) => a.name)).toContain('
|
|
150
|
-
|
|
149
|
+
expect(themeBuild.arguments.map((a) => a.name)).toContain('files');
|
|
150
|
+
const files = themeBuild.arguments.find((a) => a.name === 'files');
|
|
151
|
+
expect(files.required).toBe(true);
|
|
152
|
+
expect(files.variadic).toBe(true);
|
|
151
153
|
});
|
|
152
154
|
});
|
|
153
155
|
|
|
@@ -68,6 +68,23 @@ describe('warnOnIntegrationIssues', () => {
|
|
|
68
68
|
expect(errLines).toHaveLength(0);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it('warns for a manifest that failed to load, which declares no roots', async () => {
|
|
72
|
+
// loadIntegrations records __loadError and no contribution roots, so every
|
|
73
|
+
// on-disk check finds nothing — without the load error itself counting as
|
|
74
|
+
// an issue, a stale manifest is silent everywhere.
|
|
75
|
+
const integration = {
|
|
76
|
+
...loaded({name: '@acme/stale'}),
|
|
77
|
+
__loadError: 'createIntegration is not a function',
|
|
78
|
+
};
|
|
79
|
+
await warnOnIntegrationIssues([integration], {json: false});
|
|
80
|
+
|
|
81
|
+
expect(errLines).toHaveLength(1);
|
|
82
|
+
expect(errLines[0]).toBe(
|
|
83
|
+
'Warning: @acme/stale has 1 integration issue(s). ' +
|
|
84
|
+
'Run: astryx validate-integration @acme/stale',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
71
88
|
it('emits nothing for an integration with no issues', async () => {
|
|
72
89
|
// Existing components root with no broken contributions → no issues.
|
|
73
90
|
const componentsRoot = path.join(tmpDir, 'components');
|
|
@@ -149,6 +149,13 @@ export async function validateLoadedIntegration(loaded) {
|
|
|
149
149
|
/** @type {Issue[]} */
|
|
150
150
|
const issues = [];
|
|
151
151
|
if (!loaded || typeof loaded !== 'object') return issues;
|
|
152
|
+
// A manifest that threw on import (or failed the schema) carries a load-error
|
|
153
|
+
// marker and no contribution roots, so every check below would find nothing
|
|
154
|
+
// and report a clean integration — which is how a stale manifest used to go
|
|
155
|
+
// silently invisible. The load error IS the issue.
|
|
156
|
+
if (loaded.__loadError) {
|
|
157
|
+
return [issueError('integration_error', loaded.__loadError)];
|
|
158
|
+
}
|
|
152
159
|
checkRoots(
|
|
153
160
|
{
|
|
154
161
|
components: loaded.components,
|
|
@@ -179,6 +179,11 @@ export const doc = {
|
|
|
179
179
|
description:
|
|
180
180
|
'The --check receipt: theme name, an upToDate flag, the stale outputs (each {path, reason: missing | outdated}), and the full list of checked paths. Writes nothing.',
|
|
181
181
|
},
|
|
182
|
+
{
|
|
183
|
+
value: 'theme.build.batch',
|
|
184
|
+
description:
|
|
185
|
+
'Several themes built in one invocation: `count` plus one {file, receipt} per theme in argument order, where receipt is that theme\'s theme.build (or theme.build.check) envelope, or null when it produced no CSS.',
|
|
186
|
+
},
|
|
182
187
|
{
|
|
183
188
|
value: 'theme.list',
|
|
184
189
|
description:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.4.3-canary.
|
|
3
|
+
"version": "0.4.3-canary.b3dd679",
|
|
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",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"agent-docs"
|
|
26
26
|
],
|
|
27
27
|
"type": "module",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.13.0"
|
|
30
|
+
},
|
|
28
31
|
"bin": {
|
|
29
32
|
"astryx": "./clients/cli/bin/astryx.mjs",
|
|
30
33
|
"cli": "./clients/cli/bin/astryx.mjs"
|
|
@@ -84,10 +87,10 @@
|
|
|
84
87
|
"zod": "^4.4.3"
|
|
85
88
|
},
|
|
86
89
|
"peerDependencies": {
|
|
87
|
-
"@astryxdesign/charts": "0.4.3-canary.
|
|
88
|
-
"@astryxdesign/core": "0.4.3-canary.
|
|
89
|
-
"@astryxdesign/lab": "0.4.3-canary.
|
|
90
|
-
"@astryxdesign/theme-neutral": "0.4.3-canary.
|
|
90
|
+
"@astryxdesign/charts": "0.4.3-canary.b3dd679",
|
|
91
|
+
"@astryxdesign/core": "0.4.3-canary.b3dd679",
|
|
92
|
+
"@astryxdesign/lab": "0.4.3-canary.b3dd679",
|
|
93
|
+
"@astryxdesign/theme-neutral": "0.4.3-canary.b3dd679",
|
|
91
94
|
"gpt-tokenizer": "^3.4.0"
|
|
92
95
|
},
|
|
93
96
|
"peerDependenciesMeta": {
|
|
@@ -105,10 +108,10 @@
|
|
|
105
108
|
}
|
|
106
109
|
},
|
|
107
110
|
"devDependencies": {
|
|
108
|
-
"@astryxdesign/charts": "0.4.3-canary.
|
|
109
|
-
"@astryxdesign/core": "0.4.3-canary.
|
|
110
|
-
"@astryxdesign/lab": "0.4.3-canary.
|
|
111
|
-
"@astryxdesign/theme-neutral": "0.4.3-canary.
|
|
111
|
+
"@astryxdesign/charts": "0.4.3-canary.b3dd679",
|
|
112
|
+
"@astryxdesign/core": "0.4.3-canary.b3dd679",
|
|
113
|
+
"@astryxdesign/lab": "0.4.3-canary.b3dd679",
|
|
114
|
+
"@astryxdesign/theme-neutral": "0.4.3-canary.b3dd679",
|
|
112
115
|
"gpt-tokenizer": "^3.4.0"
|
|
113
116
|
},
|
|
114
117
|
"scripts": {
|