@astryxdesign/cli 0.4.2-canary.464a445 → 0.4.2-canary.4d004cd

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.
@@ -15,15 +15,34 @@
15
15
  * declared rather than inferred. Absent the flag, output is byte-for-byte what
16
16
  * it was before, which keeps the default no-`--out` flow — where the neighbour
17
17
  * is an uncompiled `icons.tsx` that only a bundler can resolve — working.
18
+ *
19
+ * The spawned-process block at the bottom pins what only real processes can
20
+ * prove: the emitted module actually loads under Node ESM, and the watch
21
+ * loop's child re-invocations carry the flag to every rebuild.
18
22
  */
19
23
 
20
24
  import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
25
+ import {spawn, spawnSync} from 'node:child_process';
21
26
  import * as fs from 'node:fs';
22
27
  import * as path from 'node:path';
23
28
  import * as os from 'node:os';
29
+ import {fileURLToPath, pathToFileURL} from 'node:url';
24
30
  import {ensureCoreBuilt} from './ensure-core-built.mjs';
25
31
  import {runCli} from '../../../test-utils/run-cli.mjs';
26
32
 
33
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+ const CLI_BIN = path.resolve(__dirname, '../bin/astryx.mjs');
35
+
36
+ /** Poll until `predicate()` is true or the timeout elapses. */
37
+ async function waitFor(predicate, {timeout = 20000, interval = 100} = {}) {
38
+ const start = Date.now();
39
+ for (;;) {
40
+ if (predicate()) return true;
41
+ if (Date.now() - start > timeout) return false;
42
+ await new Promise(r => setTimeout(r, interval));
43
+ }
44
+ }
45
+
27
46
  /**
28
47
  * The emitted icon import, or null. Reads the statement rather than the whole
29
48
  * file: the `@generated` header quotes the source filename and a usage example,
@@ -142,13 +161,7 @@ describe('theme build --icons-specifier', () => {
142
161
  const relativeTheme = path.relative(project, themeFile);
143
162
 
144
163
  const built = await runCli(
145
- [
146
- 'theme',
147
- 'build',
148
- relativeTheme,
149
- '--icons-specifier',
150
- './icons.mjs',
151
- ],
164
+ ['theme', 'build', relativeTheme, '--icons-specifier', './icons.mjs'],
152
165
  project,
153
166
  );
154
167
  expect(built.code).toBe(0);
@@ -223,3 +236,114 @@ describe('theme build --icons-specifier', () => {
223
236
  expect(generated).not.toContain('icons:');
224
237
  });
225
238
  });
239
+
240
+ describe('theme build --icons-specifier (spawned processes)', () => {
241
+ it('emits a module Node can actually load', async () => {
242
+ const project = path.join(tmpDir, 'project');
243
+ const themeFile = writeThemeWithIcons(project, 'loadable');
244
+
245
+ const result = await runCli(
246
+ [
247
+ 'theme',
248
+ 'build',
249
+ path.relative(project, themeFile),
250
+ '--icons-specifier',
251
+ './icons.mjs',
252
+ ],
253
+ project,
254
+ );
255
+ expect(result.code).toBe(0);
256
+
257
+ // The text assertions above prove the emitted line; only a real Node
258
+ // process proves the module resolves and evaluates. That distinction is
259
+ // the regression #4620 shipped: every byte existed, none of them loaded.
260
+ const builtUrl = pathToFileURL(path.join(project, 'loadable.js'));
261
+ const probe = spawnSync(
262
+ process.execPath,
263
+ [
264
+ '--input-type=module',
265
+ '-e',
266
+ `const m = await import(${JSON.stringify(builtUrl.href)});` +
267
+ `if (m.loadableTheme?.name !== 'loadable') throw new Error('bad theme export');` +
268
+ `if (typeof m.testIcons !== 'object') throw new Error('bad registry export');`,
269
+ ],
270
+ {encoding: 'utf8'},
271
+ );
272
+ expect(probe.stderr).toBe('');
273
+ expect(probe.status).toBe(0);
274
+ });
275
+
276
+ it('watch mode forwards the flag to every rebuild', async () => {
277
+ const project = path.join(tmpDir, 'project');
278
+ const themeFile = writeThemeWithIcons(project, 'watched');
279
+ const cssFile = path.join(project, 'out', 'theme.css');
280
+ const builtFile = path.join(project, 'out', 'watched.js');
281
+ const declaredImport = 'import { testIcons } from "./icons.mjs";';
282
+
283
+ const child = spawn(
284
+ process.execPath,
285
+ [
286
+ CLI_BIN,
287
+ 'theme',
288
+ 'build',
289
+ path.relative(project, themeFile),
290
+ '--out',
291
+ 'out/theme.css',
292
+ '--icons-specifier',
293
+ './icons.mjs',
294
+ '--watch',
295
+ ],
296
+ {cwd: project, env: {...process.env, FORCE_COLOR: '0'}},
297
+ );
298
+ let output = '';
299
+ child.stdout.on('data', d => (output += d.toString()));
300
+ child.stderr.on('data', d => (output += d.toString()));
301
+
302
+ try {
303
+ // Initial build: the declared specifier reaches the module.
304
+ expect(await waitFor(() => fs.existsSync(cssFile))).toBe(true);
305
+ expect(await waitFor(() => /Watching/i.test(output))).toBe(true);
306
+ expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
307
+ declaredImport,
308
+ );
309
+
310
+ // Rebuilds run through a child re-invocation of `theme build`, so the
311
+ // flag reaches them only if the watch loop forwards it. Change a token
312
+ // and wait for the rebuilt CSS. fs.watch delivery is best-effort under
313
+ // load, so re-touch until the rebuild shows up (idempotent write).
314
+ const touched =
315
+ `import {testIcons} from './icons';\n` +
316
+ `export default {\n` +
317
+ ` name: "watched",\n` +
318
+ ` icons: testIcons,\n` +
319
+ ` tokens: {'--color-bg': '#0a0b0c'},\n` +
320
+ `};\n`;
321
+ fs.writeFileSync(themeFile, touched);
322
+ const rebuilt = await waitFor(() => {
323
+ try {
324
+ if (fs.readFileSync(cssFile, 'utf-8').includes('#0a0b0c'))
325
+ return true;
326
+ } catch {
327
+ // CSS mid-write; fall through to re-touch.
328
+ }
329
+ try {
330
+ fs.writeFileSync(themeFile, touched);
331
+ } catch {
332
+ // Retried on the next poll.
333
+ }
334
+ return false;
335
+ });
336
+ expect(rebuilt).toBe(true);
337
+
338
+ // The regenerated module still carries the declared specifier — the
339
+ // forwarding is what this test pins. A watch loop that dropped the flag
340
+ // would regenerate with the scraped './icons' here and ship the #4620
341
+ // bytes on every save.
342
+ expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
343
+ declaredImport,
344
+ );
345
+ } finally {
346
+ child.kill('SIGINT');
347
+ }
348
+ }, 60_000);
349
+ });
@@ -154,9 +154,9 @@ export function sanitizeName(name, options = {}) {
154
154
  );
155
155
  }
156
156
 
157
- if (name === '.' || name === '..' || name.startsWith('..')) {
157
+ if (name === '.' || name === '..' || name.startsWith('.')) {
158
158
  throw new PathSafetyError(
159
- `Invalid ${label} "${name}": must not be '.' or start with '..'.`,
159
+ `Invalid ${label} "${name}": must not start with '.'.`,
160
160
  'NAME_TRAVERSAL',
161
161
  );
162
162
  }
@@ -91,6 +91,13 @@ describe('sanitizeName', () => {
91
91
  expect(() => sanitizeName('.')).toThrow(PathSafetyError);
92
92
  });
93
93
 
94
+ it('rejects dotfile names (.env, .htaccess) that could create hidden files', () => {
95
+ expect(() => sanitizeName('.env')).toThrow(PathSafetyError);
96
+ expect(() => sanitizeName('.htaccess')).toThrow(PathSafetyError);
97
+ expect(() => sanitizeName('.bashrc')).toThrow(PathSafetyError);
98
+ expect(() => sanitizeName('.gitignore')).toThrow(PathSafetyError);
99
+ });
100
+
94
101
  it('rejects NUL bytes', () => {
95
102
  expect(() => sanitizeName('foo\0bar')).toThrow(PathSafetyError);
96
103
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.4.2-canary.464a445",
3
+ "version": "0.4.2-canary.4d004cd",
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",
@@ -84,10 +84,10 @@
84
84
  "zod": "^4.4.3"
85
85
  },
86
86
  "peerDependencies": {
87
- "@astryxdesign/charts": "0.4.2-canary.464a445",
88
- "@astryxdesign/core": "0.4.2-canary.464a445",
89
- "@astryxdesign/lab": "0.4.2-canary.464a445",
90
- "@astryxdesign/theme-neutral": "0.4.2-canary.464a445",
87
+ "@astryxdesign/charts": "0.4.2-canary.4d004cd",
88
+ "@astryxdesign/core": "0.4.2-canary.4d004cd",
89
+ "@astryxdesign/lab": "0.4.2-canary.4d004cd",
90
+ "@astryxdesign/theme-neutral": "0.4.2-canary.4d004cd",
91
91
  "gpt-tokenizer": "^3.4.0"
92
92
  },
93
93
  "peerDependenciesMeta": {
@@ -105,10 +105,10 @@
105
105
  }
106
106
  },
107
107
  "devDependencies": {
108
- "@astryxdesign/charts": "0.4.2-canary.464a445",
109
- "@astryxdesign/core": "0.4.2-canary.464a445",
110
- "@astryxdesign/lab": "0.4.2-canary.464a445",
111
- "@astryxdesign/theme-neutral": "0.4.2-canary.464a445",
108
+ "@astryxdesign/charts": "0.4.2-canary.4d004cd",
109
+ "@astryxdesign/core": "0.4.2-canary.4d004cd",
110
+ "@astryxdesign/lab": "0.4.2-canary.4d004cd",
111
+ "@astryxdesign/theme-neutral": "0.4.2-canary.4d004cd",
112
112
  "gpt-tokenizer": "^3.4.0"
113
113
  },
114
114
  "scripts": {