@astryxdesign/cli 0.4.2 → 0.4.3-canary.00bed29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +45 -43
  3. package/api/discover/_adapter.d.mts +5 -3
  4. package/api/discover/_adapter.mjs +6 -4
  5. package/api/template/cdn/cdn.d.mts +23 -0
  6. package/api/template/cdn/cdn.mjs +86 -0
  7. package/api/template/cdn/cdn.test.mjs +108 -0
  8. package/api/template/template.d.mts +2 -0
  9. package/api/template/template.doc.mjs +21 -3
  10. package/api/template/template.mjs +16 -2
  11. package/api/template/template.type.d.mts +19 -0
  12. package/api/template/template.type.mjs +12 -0
  13. package/api/theme/build/build.font-warning.test.mjs +23 -14
  14. package/api/theme/build/build.icons-specifier.test.mjs +149 -0
  15. package/api/theme/build/build.mjs +88 -16
  16. package/api/theme/build/build.test.mjs +185 -14
  17. package/api/theme/theme.type.d.mts +22 -0
  18. package/api/theme/theme.type.mjs +15 -1
  19. package/assets/cdn.template.html +124 -0
  20. package/assets/docs/theme.doc.dense.mjs +1 -1
  21. package/assets/docs/theme.doc.mjs +11 -5
  22. package/assets/docs/theme.doc.zh.mjs +1 -1
  23. package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.doc.mjs +21 -0
  24. package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.tsx +46 -0
  25. package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.doc.mjs +23 -0
  26. package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.tsx +100 -0
  27. package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.doc.mjs +22 -0
  28. package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.tsx +47 -0
  29. package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.doc.mjs +22 -0
  30. package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.tsx +51 -0
  31. package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.doc.mjs +26 -0
  32. package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.tsx +221 -0
  33. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
  34. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
  35. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
  36. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
  37. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
  38. package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
  39. package/assets/templates/themes/neutral/neutralTheme.ts +6 -4
  40. package/assets/theme.template.ts +4 -3
  41. package/authoring/doctypes/base/type.ts +9 -0
  42. package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
  43. package/clients/cli/commands/build-theme.icons-specifier.test.mjs +132 -7
  44. package/clients/cli/commands/build-theme.mjs +154 -65
  45. package/clients/cli/commands/build-theme.multi.test.mjs +148 -0
  46. package/clients/cli/commands/build-theme.watch.test.mjs +67 -0
  47. package/clients/cli/commands/discover.broken-integration.test.mjs +112 -0
  48. package/clients/cli/commands/discover.mjs +12 -0
  49. package/clients/cli/commands/search.mjs +9 -0
  50. package/clients/cli/commands/template-cdn.behavior.test.mjs +113 -0
  51. package/clients/cli/commands/template.doc.mjs +12 -2
  52. package/clients/cli/commands/template.mjs +24 -3
  53. package/clients/cli/commands/theme-build.doc.mjs +11 -5
  54. package/clients/cli/lib/json-shim.test.mjs +1 -1
  55. package/clients/cli/lib/manifest.mjs +3 -2
  56. package/clients/cli/lib/manifest.test.mjs +4 -2
  57. package/foundation/fs/path-safety.mjs +2 -2
  58. package/foundation/fs/path-safety.test.mjs +7 -0
  59. package/foundation/integrations/integration-warnings.test.mjs +17 -0
  60. package/foundation/integrations/validate-contributions.mjs +7 -0
  61. package/foundation/response/response-types.doc.mjs +11 -0
  62. package/foundation/text/copyright-header.mjs +11 -4
  63. package/package.json +12 -9
  64. package/assets/templates/pages/table-page-chart/page.tsx +0 -577
  65. package/assets/templates/pages/table-page-chart/template.doc.mjs +0 -13
  66. package/assets/templates/pages/table-page-heatmap-status/page.tsx +0 -467
  67. package/assets/templates/pages/table-page-heatmap-status/template.doc.mjs +0 -13
  68. package/assets/templates/pages/table-page-shoe-store-heatmap/page.tsx +0 -931
  69. package/assets/templates/pages/table-page-shoe-store-heatmap/template.doc.mjs +0 -13
@@ -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 a theme file and rebuild on change. Runs an initial build, then
84
- * rebuilds (debounced) whenever the file changes, until interrupted with
85
- * Ctrl-C. Each rebuild runs in a child process so a build error (which the
86
- * single-build path reports via a hard exit) is contained and the watcher
87
- * keeps running.
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} file - The theme file argument, as the user passed it.
90
- * @param {string} filePath - Absolute path to the theme file.
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(file, filePath, options) {
95
- const rel = path.relative(process.cwd(), filePath);
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
- await runThemeBuildOnceChild(file, options);
109
+ for (const entry of entries) {
110
+ await runThemeBuildOnceChild(entry.file, options);
111
+ }
99
112
 
100
113
  let building = false;
101
- let queued = false;
102
- /** @type {ReturnType<typeof setTimeout> | undefined} */
103
- let debounce;
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
- const rebuild = async () => {
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 = true;
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(`\nWatching ${rel} for changes — press Ctrl-C to stop.`));
116
- if (queued) {
117
- queued = false;
118
- rebuild();
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 watchDir = path.dirname(filePath);
126
- const baseName = path.basename(filePath);
127
- const watcher = fs.watch(watchDir, (_eventType, changed) => {
128
- if (changed && changed !== baseName) return;
129
- clearTimeout(debounce);
130
- // Debounce: editors often emit several events per save.
131
- debounce = setTimeout(rebuild, 100);
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(`\nWatching ${rel} for changes — press Ctrl-C to stop.`));
157
+ emit(text(watchingLine));
138
158
 
139
159
  await new Promise((/** @type {(value?: void) => void} */ resolve) => {
140
160
  const stop = () => {
141
- clearTimeout(debounce);
142
- watcher.close();
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} */ file,
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
- if (!fs.existsSync(filePath)) {
218
- cliError(`File not found: ${filePath}`, {
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(file, filePath, options);
292
+ await runThemeBuildWatch(entries, options);
244
293
  return;
245
294
  }
246
295
 
247
- // Non-watch: delegate to the API compiler. Enable human output unless in
248
- // --json mode (log → stdout via humanLog, warn/error → stderr). The
249
- // "Building theme from" line, the ✓/warning lines, and the install
250
- // instructions are all emitted from inside themeBuild via the shared logger.
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
- try {
253
- const result = await themeBuild(
254
- file,
255
- {
256
- out: options.out,
257
- check: options.check,
258
- iconsSpecifier: options.iconsSpecifier,
259
- },
260
- {cwd: process.cwd()},
261
- );
262
- if (json && result) jsonOut(result);
263
- // In --check mode a stale/missing output is a failure: exit non-zero
264
- // (after emitting the receipt) so CI can gate on it. The receipt is
265
- // already printed above (shared logger or --json envelope).
266
- if (
267
- options.check &&
268
- result &&
269
- result.type === 'theme.build.check' &&
270
- !result.data.upToDate
271
- ) {
272
- process.exitCode = 1;
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
- } catch (e) {
275
- const err =
276
- /** @type {import('../../../api/error.mjs').AstryxError} */ (e);
277
- cliError(err.message, {suggestions: err.suggestions, code: err.code});
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.