@astryxdesign/cli 0.6.3-canary.d0d49a4 → 0.6.3-canary.dea6813

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 (183) hide show
  1. package/README.md +32 -18
  2. package/api/build/build.doc.mjs +3 -3
  3. package/api/build/build.test.mjs +14 -3
  4. package/api/build/build.type.d.mts +35 -1
  5. package/api/build/build.type.mjs +24 -3
  6. package/api/build/help/help.d.mts +8 -5
  7. package/api/build/help/help.mjs +56 -6
  8. package/api/component/component.doc.mjs +11 -2
  9. package/api/component/component.mjs +29 -0
  10. package/api/component/component.test.mjs +38 -0
  11. package/api/component/component.type.d.mts +11 -1
  12. package/api/component/component.type.mjs +8 -1
  13. package/api/discover/discover.doc.mjs +1 -1
  14. package/api/gap-report/gap-report.doc.mjs +7 -4
  15. package/api/init/init.doc.mjs +4 -0
  16. package/api/init/init.test.mjs +41 -1
  17. package/api/init/remove/remove.mjs +1 -1
  18. package/api/init/run/run.mjs +20 -10
  19. package/api/integration/add-contribution.component-names.test.mjs +120 -0
  20. package/api/integration/add-contribution.mjs +11 -0
  21. package/api/integration/add-contribution.test.mjs +80 -0
  22. package/api/integration/integrationAdd.doc.mjs +7 -4
  23. package/api/integration/integrationAddComponent.doc.mjs +1 -1
  24. package/api/integration/validate-integration.mjs +2 -0
  25. package/api/integration/validate-integration.test.mjs +91 -0
  26. package/api/integration/validateIntegration.doc.mjs +1 -1
  27. package/api/json/envelope-types.test.mjs +76 -0
  28. package/api/json/parseResponse.doc.mjs +2 -2
  29. package/api/layout/expand/expand.mjs +7 -5
  30. package/api/layout/expand/expand.path-safety.test.mjs +53 -0
  31. package/api/layout/grammar/grammar.mjs +2 -1
  32. package/api/layout/layoutExpand.doc.mjs +1 -1
  33. package/api/search/search-return-type.test.mjs +54 -0
  34. package/api/search/search.d.mts +2 -9
  35. package/api/search/search.doc.mjs +5 -1
  36. package/api/search/search.mjs +10 -3
  37. package/api/search/search.test.mjs +34 -0
  38. package/api/swizzle/copy/copy.mjs +28 -11
  39. package/api/swizzle/swizzle.doc.mjs +1 -1
  40. package/api/template/copy/copy.mjs +17 -23
  41. package/api/template/copy/copy.test.mjs +17 -0
  42. package/api/template/template.doc.mjs +2 -1
  43. package/api/theme/add/add.binary.test.mjs +91 -0
  44. package/api/theme/add/add.mjs +20 -3
  45. package/api/theme/add/add.staging.test.mjs +66 -0
  46. package/api/theme/build/build.mjs +114 -37
  47. package/api/theme/build/build.public-component-vars.test.mjs +1 -1
  48. package/api/theme/build/build.receipt-doc.test.mjs +111 -0
  49. package/api/theme/build/font-warning.mjs +3 -3
  50. package/api/theme/build/font-warning.test.mjs +5 -2
  51. package/api/theme/palette/generate/generate.mjs +7 -2
  52. package/api/theme/palette/generate/generate.test.mjs +96 -0
  53. package/api/theme/palette/generate/generator.mjs +8 -1
  54. package/api/theme/palette/generate/generator.test.mjs +10 -0
  55. package/api/theme/template/template.mjs +11 -2
  56. package/api/theme/template/template.test.mjs +15 -0
  57. package/api/theme/themeBuild.doc.mjs +7 -4
  58. package/api/theme/themeTemplate.doc.mjs +4 -1
  59. package/api/upgrade/_adapter.mjs +27 -4
  60. package/api/upgrade/list/list.mjs +2 -1
  61. package/api/upgrade/list/list.test.mjs +73 -0
  62. package/api/upgrade/status/status.mjs +2 -2
  63. package/api/upgrade/upgrade.doc.mjs +2 -1
  64. package/api/upgrade/upgrade.type.d.mts +4 -0
  65. package/api/upgrade/upgrade.type.mjs +1 -0
  66. package/assets/codemods/term-log.mjs +32 -8
  67. package/assets/codemods/term-log.test.mjs +19 -1
  68. package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
  69. package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
  70. package/assets/templates/pages/table-tree/page.tsx +1704 -0
  71. package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
  72. package/authoring/config/config.doc.mjs +1 -1
  73. package/authoring/config/debug-composition.test.mjs +92 -0
  74. package/authoring/config/type.ts +15 -3
  75. package/clients/cli/command-load-failure.test.mjs +83 -0
  76. package/clients/cli/commands/blog.mjs +23 -8
  77. package/clients/cli/commands/blog.test.mjs +42 -1
  78. package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
  79. package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
  80. package/clients/cli/commands/build-theme.mjs +5 -5
  81. package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
  82. package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
  83. package/clients/cli/commands/build.doc.mjs +4 -1
  84. package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
  85. package/clients/cli/commands/build.mjs +32 -47
  86. package/clients/cli/commands/build.playbook.test.mjs +75 -0
  87. package/clients/cli/commands/build.text-fields.test.mjs +40 -0
  88. package/clients/cli/commands/component/index.mjs +2 -7
  89. package/clients/cli/commands/component-package.test.mjs +18 -0
  90. package/clients/cli/commands/component-resolution.test.mjs +21 -0
  91. package/clients/cli/commands/component.test.mjs +19 -0
  92. package/clients/cli/commands/detail-levels.test.mjs +2 -2
  93. package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
  94. package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
  95. package/clients/cli/commands/discover.doc.mjs +4 -2
  96. package/clients/cli/commands/discover.mjs +3 -3
  97. package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
  98. package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
  99. package/clients/cli/commands/doctor.mjs +6 -16
  100. package/clients/cli/commands/doctor.test.mjs +42 -0
  101. package/clients/cli/commands/gap-report.doc.mjs +16 -5
  102. package/clients/cli/commands/gap-report.test.mjs +72 -0
  103. package/clients/cli/commands/hook/index.mjs +7 -17
  104. package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
  105. package/clients/cli/commands/init.doc.mjs +19 -8
  106. package/clients/cli/commands/init.flag-help.test.mjs +153 -0
  107. package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
  108. package/clients/cli/commands/integration-add.doc.mjs +21 -5
  109. package/clients/cli/commands/interactive-guard.test.mjs +101 -24
  110. package/clients/cli/commands/json-contract.test.mjs +33 -0
  111. package/clients/cli/commands/layout-check.doc.mjs +14 -3
  112. package/clients/cli/commands/layout-expand.doc.mjs +21 -4
  113. package/clients/cli/commands/layout.doc.mjs +2 -2
  114. package/clients/cli/commands/layout.mjs +21 -9
  115. package/clients/cli/commands/layout.path-help.test.mjs +33 -0
  116. package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
  117. package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
  118. package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
  119. package/clients/cli/commands/search.doc.mjs +6 -3
  120. package/clients/cli/commands/search.mjs +17 -7
  121. package/clients/cli/commands/search.test.mjs +75 -0
  122. package/clients/cli/commands/swizzle.doc.mjs +2 -1
  123. package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
  124. package/clients/cli/commands/template.doc.mjs +28 -6
  125. package/clients/cli/commands/template.flag-help.test.mjs +117 -0
  126. package/clients/cli/commands/template.path-help.test.mjs +40 -0
  127. package/clients/cli/commands/theme-add.doc.mjs +2 -1
  128. package/clients/cli/commands/theme-build.doc.mjs +7 -6
  129. package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
  130. package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
  131. package/clients/cli/commands/theme-template.doc.mjs +1 -1
  132. package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
  133. package/clients/cli/commands/upgrade.doc.mjs +17 -6
  134. package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
  135. package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
  136. package/clients/cli/index.mjs +13 -30
  137. package/clients/cli/latest-version-env.test.mjs +50 -0
  138. package/clients/cli/lib/cli-error.test.mjs +7 -0
  139. package/clients/cli/lib/component-format.mjs +9 -9
  140. package/clients/cli/lib/component-format.test.mjs +1 -1
  141. package/clients/cli/lib/define-command.mjs +32 -6
  142. package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
  143. package/clients/cli/lib/exit-codes.test.mjs +97 -0
  144. package/clients/cli/lib/hook-format.mjs +5 -5
  145. package/clients/cli/lib/json-shim.mjs +38 -2
  146. package/clients/cli/lib/json-shim.test.mjs +83 -0
  147. package/clients/cli/lib/manifest.d.ts +2 -0
  148. package/clients/cli/lib/manifest.mjs +22 -0
  149. package/clients/cli/lib/manifest.test.mjs +17 -0
  150. package/foundation/agent-docs/agent-docs.d.mts +4 -0
  151. package/foundation/agent-docs/agent-docs.mjs +69 -3
  152. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
  153. package/foundation/config/integration-debug.test.mjs +28 -3
  154. package/foundation/config/project.mjs +123 -9
  155. package/foundation/config/project.test.mjs +126 -0
  156. package/foundation/discovery/template-adapter.d.mts +14 -5
  157. package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
  158. package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
  159. package/foundation/discovery/template-adapter.mjs +292 -25
  160. package/foundation/discovery/theming-targets.test.mjs +4 -0
  161. package/foundation/fs/module-loader.d.mts +1 -0
  162. package/foundation/fs/module-loader.mjs +50 -1
  163. package/foundation/fs/module-loader.stdout.test.mjs +332 -0
  164. package/foundation/fs/path-safety.d.mts +3 -2
  165. package/foundation/fs/path-safety.mjs +49 -19
  166. package/foundation/fs/path-safety.test.mjs +50 -0
  167. package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
  168. package/foundation/integrations/autolink.mjs +6 -1
  169. package/foundation/response/base.d.ts +8 -4
  170. package/foundation/response/error-codes.doc.mjs +1 -2
  171. package/foundation/response/error-codes.mjs +1 -1
  172. package/foundation/response/error-codes.test.mjs +83 -0
  173. package/foundation/response/json-contract.test.mjs +11 -0
  174. package/foundation/response/json.d.mts +4 -2
  175. package/foundation/response/json.mjs +8 -10
  176. package/foundation/response/response-types.doc.mjs +3 -3
  177. package/foundation/xle/expand.d.mts +2 -0
  178. package/foundation/xle/expand.mjs +3 -2
  179. package/foundation/xle/expand.test.mjs +54 -0
  180. package/package.json +9 -9
  181. package/clients/cli/lib/update-check.mjs +0 -83
  182. package/clients/cli/lib/update-check.test.mjs +0 -137
  183. package/clients/cli/update-hint-commands.test.mjs +0 -54
@@ -0,0 +1,12 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'page',
6
+ name: 'Tree Table',
7
+ displayName: 'Tree Table',
8
+ description:
9
+ 'Hierarchical table where every parent row rolls up from its children — a code repository whose folders show the newest commit beneath them, beside a detail sidebar and a rendered README. Columns resize, siblings sort within their own level, arrow keys walk the rows, and search prunes the tree to matching branches. Tree, hierarchy, nested rows, drilldown, expand, collapse, resizable, expandable, file browser, repository, rolled-up parents.',
10
+ isReady: true,
11
+ category: 'Table - Tree/Hierarchical List',
12
+ };
@@ -48,7 +48,7 @@ export const doc = {
48
48
  name: 'debug',
49
49
  type: '(event: DebugEvent) => void',
50
50
  description:
51
- 'Record every command run. The handler is synchronous; promises are not awaited and output goes to stderr. Declare `debug` directly in this file so early commands can discover it. An integration can supply one too, as a `debug` named export from its manifest — both run; set `{"astryx": {"inheritDebug": false}}` in package.json to take only your own.',
51
+ 'Record every command run. The handler is synchronous; promises are not awaited and output goes to stderr. Declare `debug` directly in this file so early commands can discover it. An integration can supply one too, as a `debug` named export from its manifest. Every handler runs: yours first, then each integration\'s in load order (the `integrations` list, then autolinked ones). A handler that throws is skipped; the others still run and the command\'s result does not change. Set `{"astryx": {"inheritDebug": false}}` in package.json to take only your own.',
52
52
  example:
53
53
  "event => appendFileSync('runs.ndjson', JSON.stringify(event) + '\\n')",
54
54
  },
@@ -0,0 +1,92 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file `debug` accepts handlers from integrations, so both public entries for
5
+ * it — the exported `AstryxConfig` type and `astryx docs authoring config` —
6
+ * must state how those handlers combine with the app's own: the contribution
7
+ * form, the order, what a failing handler does, and the refusal control.
8
+ */
9
+
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import {fileURLToPath} from 'node:url';
13
+ import ts from 'typescript';
14
+ import {describe, expect, it} from 'vitest';
15
+ import {doc} from './config.doc.mjs';
16
+
17
+ const TYPE_FILE = path.join(
18
+ path.dirname(fileURLToPath(import.meta.url)),
19
+ 'type.ts',
20
+ );
21
+ const source = ts.createSourceFile(
22
+ TYPE_FILE,
23
+ fs.readFileSync(TYPE_FILE, 'utf8'),
24
+ ts.ScriptTarget.Latest,
25
+ true,
26
+ );
27
+
28
+ /** @param {ts.Node} node @returns {string} */
29
+ function jsdocText(node) {
30
+ return ts
31
+ .getJSDocCommentsAndTags(node)
32
+ .map(jsdoc => ts.getTextOfJSDocComment(jsdoc.comment) ?? '')
33
+ .join('\n');
34
+ }
35
+
36
+ /** @param {(node: ts.Node) => boolean} match @returns {ts.Node} */
37
+ function findTopLevel(match) {
38
+ const node = source.statements.find(match);
39
+ if (!node) throw new Error('declaration not found in config/type.ts');
40
+ return node;
41
+ }
42
+
43
+ const configInterface = /** @type {ts.InterfaceDeclaration} */ (
44
+ findTopLevel(
45
+ node => ts.isInterfaceDeclaration(node) && node.name.text === 'AstryxConfig',
46
+ )
47
+ );
48
+ const debugMember = configInterface.members.find(
49
+ member =>
50
+ ts.isPropertySignature(member) && member.name.getText(source) === 'debug',
51
+ );
52
+ if (!debugMember) throw new Error('AstryxConfig has no debug member');
53
+
54
+ const debugConfigAlias = findTopLevel(
55
+ node => ts.isTypeAliasDeclaration(node) && node.name.text === 'DebugConfig',
56
+ );
57
+
58
+ const schemaField = doc.fields.find(field => field.name === 'debug');
59
+
60
+ const ENTRIES = {
61
+ 'the AstryxConfig type': jsdocText(debugMember),
62
+ 'astryx docs authoring config': schemaField?.description ?? '',
63
+ };
64
+
65
+ describe.each(Object.entries(ENTRIES))('debug entry in %s', (_, text) => {
66
+ it('says integrations contribute, and in what form', () => {
67
+ expect(text).toMatch(/integration/i);
68
+ expect(text).toMatch(/`debug` named export/);
69
+ });
70
+
71
+ it('says every handler runs, the app first, then integrations in load order', () => {
72
+ expect(text).toMatch(/\bfirst\b/i);
73
+ expect(text).toMatch(/load order/i);
74
+ });
75
+
76
+ it('says a handler that throws is skipped without affecting the others', () => {
77
+ expect(text).toMatch(/throws is skipped/i);
78
+ expect(text).toMatch(/others still run/i);
79
+ });
80
+
81
+ it('names the one control that refuses inherited handlers', () => {
82
+ expect(text).toContain('{"astryx": {"inheritDebug": false}}');
83
+ });
84
+ });
85
+
86
+ describe('DebugConfig', () => {
87
+ it('does not claim that leaving debug out records nothing', () => {
88
+ const text = jsdocText(debugConfigAlias);
89
+ expect(text).not.toMatch(/nothing is recorded/i);
90
+ expect(text).toMatch(/integration/i);
91
+ });
92
+ });
@@ -56,8 +56,9 @@ export interface XleComponent {
56
56
  /**
57
57
  * Record every astryx command run in this project.
58
58
  *
59
- * A function that receives each run. Setting it is the whole opt-in; leave it
60
- * out and nothing is recorded.
59
+ * A function that receives each run. Leaving it out does not stop a handler an
60
+ * integration contributes; see {@link AstryxConfig.debug} for how the two
61
+ * combine and how to refuse inherited handlers.
61
62
  *
62
63
  * ```
63
64
  * export default {
@@ -80,7 +81,18 @@ export interface AstryxConfig {
80
81
  hooks?: {
81
82
  postCodemod?: PostCodemodHook[];
82
83
  };
83
- /** Record every astryx command run in this project. See {@link DebugConfig}. */
84
+ /**
85
+ * Record every astryx command run in this project. See {@link DebugConfig}.
86
+ *
87
+ * An integration can contribute a handler too, as a `debug` named export
88
+ * from its `astryx.integration.*` module. Every handler receives the run:
89
+ * this one first, then each integration's in load order (the ones
90
+ * `integrations` lists, in that order, then autolinked ones). A handler that
91
+ * throws is skipped; the others still run and the command's result does not
92
+ * change. To refuse inherited handlers, set
93
+ * `{"astryx": {"inheritDebug": false}}` in the project's package.json; this
94
+ * handler still runs.
95
+ */
84
96
  debug?: DebugConfig;
85
97
  /** Route gap reports through a project-owned handler. See {@link GapReportHandler}. */
86
98
  gapReport?: GapReportHandler;
@@ -0,0 +1,83 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A command whose module fails to import still answers: with --json, one
5
+ * error envelope on stdout; without it, the error on stderr. Exit 1 either way.
6
+ *
7
+ * The failure is injected with a module-load hook in a real subprocess, so the
8
+ * lazy command registry in index.mjs sees what a broken install would give it.
9
+ */
10
+
11
+ import {describe, it, expect, beforeAll, afterAll} from 'vitest';
12
+ import {spawnSync} from 'node:child_process';
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import {fileURLToPath, pathToFileURL} from 'node:url';
17
+
18
+ const BIN = fileURLToPath(new URL('./bin/astryx.mjs', import.meta.url));
19
+
20
+ /** @type {string} */
21
+ let dir;
22
+ /** @type {string} */
23
+ let register;
24
+
25
+ beforeAll(() => {
26
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-load-failure-'));
27
+ const hooks = path.join(dir, 'hooks.mjs');
28
+ fs.writeFileSync(
29
+ hooks,
30
+ [
31
+ 'export async function load(url, context, nextLoad) {',
32
+ " if (url.endsWith('/clients/cli/commands/blog.mjs')) {",
33
+ " throw new Error('simulated load failure');",
34
+ ' }',
35
+ ' return nextLoad(url, context);',
36
+ '}',
37
+ '',
38
+ ].join('\n'),
39
+ );
40
+ register = path.join(dir, 'register.mjs');
41
+ fs.writeFileSync(
42
+ register,
43
+ `import {register} from 'node:module';\nregister(${JSON.stringify(pathToFileURL(hooks).href)});\n`,
44
+ );
45
+ });
46
+
47
+ afterAll(() => {
48
+ fs.rmSync(dir, {recursive: true, force: true});
49
+ });
50
+
51
+ /** @param {string[]} args */
52
+ function run(args) {
53
+ const res = spawnSync(
54
+ process.execPath,
55
+ ['--import', pathToFileURL(register).href, BIN, ...args],
56
+ {cwd: dir, encoding: 'utf-8', timeout: 20_000},
57
+ );
58
+ return {status: res.status, stdout: res.stdout, stderr: res.stderr};
59
+ }
60
+
61
+ describe('a command that fails to load', () => {
62
+ it('answers --json with one error envelope, exit 1', () => {
63
+ const {status, stdout, stderr} = run(['blog', '--json']);
64
+ expect(status).toBe(1);
65
+ expect(stderr).toBe('');
66
+ const envelope = JSON.parse(stdout);
67
+ expect(envelope).toMatchObject({apiVersion: 1, code: 'ERR_UNKNOWN'});
68
+ expect(envelope.error).toMatch(/blog.*simulated load failure/);
69
+ });
70
+
71
+ it('reports the failure on stderr without --json, exit 1', () => {
72
+ const {status, stdout, stderr} = run(['blog']);
73
+ expect(status).toBe(1);
74
+ expect(stdout).toBe('');
75
+ expect(stderr).toMatch(/blog.*failed to load.*simulated load failure/s);
76
+ });
77
+
78
+ it('leaves the other commands working', () => {
79
+ const {status, stdout} = run(['docs', '--json']);
80
+ expect(status).toBe(0);
81
+ expect(JSON.parse(stdout).type).toMatch(/^docs\./);
82
+ });
83
+ });
@@ -24,6 +24,18 @@ import {resultSet} from '../../../foundation/debug/index.mjs';
24
24
  import {doc as blogCommand} from './blog.doc.mjs';
25
25
  import {doc as blogFn} from '../../../api/blog/blog.doc.mjs';
26
26
 
27
+ /** Every field of a post in the JSON, in order; record() skips empty ones. */
28
+ const POST_FIELDS = [
29
+ 'slug',
30
+ 'title',
31
+ 'description',
32
+ 'date',
33
+ 'type',
34
+ 'authors',
35
+ 'link',
36
+ 'textUrl',
37
+ ];
38
+
27
39
  /**
28
40
  * @param {import('commander').Command} program
29
41
  */
@@ -62,22 +74,25 @@ export function registerBlog(program) {
62
74
  const {feedUrl, posts} = result.data;
63
75
  if (posts.length === 0) {
64
76
  emit(
65
- section('Astryx blog', `feed: ${feedUrl}`),
77
+ section('Astryx blog'),
78
+ record({feedUrl}),
66
79
  text('No posts found in the feed.'),
67
80
  );
68
81
  return answered;
69
82
  }
70
- // One record per post — fields mirror the JSON post shape; empty
71
- // fields (type/textUrl) are skipped by record().
72
83
  emit(
73
- section('Astryx blog', `feed: ${feedUrl}`),
74
- records(posts, {fields: ['slug', 'title', 'type', 'textUrl']}),
84
+ section('Astryx blog'),
85
+ record({feedUrl}),
86
+ records(posts, {fields: POST_FIELDS}),
75
87
  text(`Read one: ${run} astryx blog <slug>`),
76
88
  );
77
89
  } else {
78
- // blog.detail — the feed URL, then the post body emitted verbatim
79
- // (code() so article typography/spacing isn't ASCII-normalized).
80
- emit(record({feed: result.data.feedUrl}), code(result.data.text));
90
+ // blog.detail — the post's fields and the feed URL, then the body
91
+ // verbatim (code() so article typography/spacing isn't ASCII-normalized).
92
+ emit(
93
+ record(result.data, {fields: [...POST_FIELDS, 'feedUrl']}),
94
+ code(result.data.text),
95
+ );
81
96
  }
82
97
  return answered;
83
98
  },
@@ -19,7 +19,10 @@ const FEED = `<?xml version="1.0" encoding="UTF-8"?>
19
19
  <item>
20
20
  <title>How Astryx works</title>
21
21
  <link>${SITE_URL}/blog/how-astryx-works</link>
22
+ <description>Under the hood &amp; more</description>
22
23
  <category>engineering</category>
24
+ <author>Ada</author>
25
+ <author>Grace</author>
23
26
  <pubDate>Mon, 29 Jun 2026 00:00:00 GMT</pubDate>
24
27
  <atom:link rel="alternate" type="text/plain" href="${SITE_URL}/blog/how-astryx-works.txt" />
25
28
  </item>
@@ -39,6 +42,23 @@ function stubFetch(routes) {
39
42
  });
40
43
  }
41
44
 
45
+ /**
46
+ * Every non-empty field prints as a `key: value` line under its JSON key.
47
+ * @param {string} stdout
48
+ * @param {Record<string, unknown>} fields
49
+ */
50
+ function expectFields(stdout, fields) {
51
+ const lines = stdout.split('\n');
52
+ for (const [key, value] of Object.entries(fields)) {
53
+ if (value == null || value === '' || (Array.isArray(value) && !value.length)) continue;
54
+ const shown = Array.isArray(value) ? value.join(', ') : String(value);
55
+ expect(
56
+ lines.some(line => line.startsWith(`${key}:`) && line.trimEnd().endsWith(shown)),
57
+ `${key}: ${shown}`,
58
+ ).toBe(true);
59
+ }
60
+ }
61
+
42
62
  beforeEach(() => {
43
63
  vi.stubGlobal(
44
64
  'fetch',
@@ -83,10 +103,31 @@ describe('blog CLI — json-enabled', () => {
83
103
  const {status, stdout} = await runCli(['blog']);
84
104
  expect(status).toBe(0);
85
105
  expect(stdout).toMatch(/Astryx blog/);
86
- expect(stdout).toMatch(/feed:/);
106
+ expect(stdout).toMatch(/^feedUrl:/m);
87
107
  expect(stdout).toMatch(/how-astryx-works/);
88
108
  });
89
109
 
110
+ it('the text list projects every JSON field under its JSON key', async () => {
111
+ const env = JSON.parse((await runCli(['blog', '--json'])).stdout);
112
+ const {status, stdout} = await runCli(['blog']);
113
+ expect(status).toBe(0);
114
+ expectFields(stdout, {feedUrl: env.data.feedUrl});
115
+ for (const post of env.data.posts) expectFields(stdout, post);
116
+ expect(stdout).not.toMatch(/^feed:/m);
117
+ });
118
+
119
+ it('the text detail projects every JSON field, then the post body', async () => {
120
+ const env = JSON.parse(
121
+ (await runCli(['blog', 'how-astryx-works', '--json'])).stdout,
122
+ );
123
+ const {status, stdout} = await runCli(['blog', 'how-astryx-works']);
124
+ expect(status).toBe(0);
125
+ const {text, ...fields} = env.data;
126
+ expectFields(stdout, fields);
127
+ expect(stdout).toContain(text);
128
+ expect(stdout).not.toMatch(/^feed:/m);
129
+ });
130
+
90
131
  it('surfaces the API error code and exits non-zero for an unknown slug (--json)', async () => {
91
132
  const {status, stdout} = await runCli(['blog', 'no-such-post', '--json']);
92
133
  expect(status).not.toBe(0);
@@ -0,0 +1,161 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Human output of the `theme` command group stays plain ASCII.
5
+ *
6
+ * Covers every theme-build line family that carried a glyph or typographic
7
+ * punctuation: success, warning, error, notice, and font-help lines of a
8
+ * standalone build; `--check` in both states; a batch build and check; a
9
+ * family build and check; the quoted-glob suggestion; and a palette preview.
10
+ * Each case also asserts its ASCII marker, so a line that stops printing
11
+ * cannot pass the ASCII check vacuously.
12
+ */
13
+
14
+ import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
18
+ import {runCli} from '../../../test-utils/run-cli.mjs';
19
+
20
+ // Under the CLI package so fixtures resolve `@astryxdesign/core/theme`.
21
+ const CLI_ROOT = path.resolve(import.meta.dirname, '../../..');
22
+
23
+ const OCEAN = `import {defineTheme} from '@astryxdesign/core/theme';
24
+ export const oceanTheme = defineTheme({
25
+ name: 'ocean',
26
+ typography: {body: {family: 'Inter'}},
27
+ tokens: {'--color-background-body': '#ffffff'},
28
+ components: {
29
+ progressbar: {base: {color: 'red'}},
30
+ button: {base: {'--_button-radius': '2px'}},
31
+ },
32
+ });
33
+ `;
34
+ const CALM = `import {defineTheme} from '@astryxdesign/core/theme';
35
+ import {oceanTheme} from './ocean.mjs';
36
+ export const oceanCalmTheme = defineTheme({
37
+ name: 'ocean-calm',
38
+ extends: oceanTheme,
39
+ tokens: {'--color-background-body': '#eeeeee'},
40
+ });
41
+ `;
42
+
43
+ /** @type {string} */
44
+ let dir;
45
+
46
+ beforeAll(() => {
47
+ ensureCoreBuilt();
48
+ }, 200_000);
49
+
50
+ beforeEach(() => {
51
+ dir = fs.mkdtempSync(path.join(CLI_ROOT, '.tmp-theme-ascii-'));
52
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"type":"module"}\n');
53
+ fs.writeFileSync(path.join(dir, 'ocean.mjs'), OCEAN);
54
+ fs.writeFileSync(path.join(dir, 'ocean-calm.mjs'), CALM);
55
+ });
56
+
57
+ afterEach(() => {
58
+ fs.rmSync(dir, {recursive: true, force: true});
59
+ });
60
+
61
+ /** @param {{stdout: string, stderr: string}} result */
62
+ function nonAscii(result) {
63
+ return [
64
+ ...new Set(`${result.stdout}${result.stderr}`.match(/[\u0080-\uffff]/g)),
65
+ ];
66
+ }
67
+
68
+ /** @param {string[]} args */
69
+ function run(args) {
70
+ return runCli(['theme', ...args], dir);
71
+ }
72
+
73
+ describe('theme command human output is plain ASCII', () => {
74
+ it('a standalone build: ok, warning, error, notice, and font help lines', async () => {
75
+ const result = await run(['build', 'ocean.mjs']);
76
+
77
+ expect(result.code).toBe(0);
78
+ expect(result.stdout).toContain('[ok] ocean.css');
79
+ expect(result.stderr).toContain('[warn] Deprecated component target');
80
+ expect(result.stderr).toContain('[error] Component "button"');
81
+ expect(result.stdout).toContain('note: Font "Inter"');
82
+ expect(result.stdout).toContain('[note] Theme "ocean" names fonts');
83
+ expect(nonAscii(result)).toEqual([]);
84
+ });
85
+
86
+ it('--check, up to date and stale', async () => {
87
+ await run(['build', 'ocean.mjs']);
88
+ const upToDate = await run(['build', 'ocean.mjs', '--check']);
89
+ expect(upToDate.code).toBe(0);
90
+ expect(upToDate.stdout).toContain('[ok] Theme outputs are up to date');
91
+ expect(nonAscii(upToDate)).toEqual([]);
92
+
93
+ fs.appendFileSync(path.join(dir, 'ocean.css'), '/* drift */\n');
94
+ const stale = await run(['build', 'ocean.mjs', '--check']);
95
+ expect(stale.code).toBe(1);
96
+ expect(stale.stderr).toContain('[fail] 1 theme output(s) are out of date');
97
+ expect(nonAscii(stale)).toEqual([]);
98
+ });
99
+
100
+ it('a batch build and check', async () => {
101
+ const built = await run(['build', 'ocean.mjs', 'ocean-calm.mjs']);
102
+ expect(built.code).toBe(0);
103
+ expect(built.stdout).toContain('[ok] Built 2 themes.');
104
+ expect(nonAscii(built)).toEqual([]);
105
+
106
+ const checked = await run([
107
+ 'build',
108
+ 'ocean.mjs',
109
+ 'ocean-calm.mjs',
110
+ '--check',
111
+ ]);
112
+ expect(checked.code).toBe(0);
113
+ expect(checked.stdout).toContain('[ok] Checked 2 themes.');
114
+ expect(nonAscii(checked)).toEqual([]);
115
+ });
116
+
117
+ it('a family build and a stale family check', async () => {
118
+ const familyArgs = [
119
+ 'build',
120
+ '--family',
121
+ 'ocean.mjs',
122
+ 'ocean-calm.mjs',
123
+ '--family-key',
124
+ 'ocean-family',
125
+ ];
126
+ const built = await run(familyArgs);
127
+ expect(built.code).toBe(0);
128
+ expect(built.stdout).toContain('[ok] ocean-family.css');
129
+ expect(nonAscii(built)).toEqual([]);
130
+
131
+ fs.rmSync(path.join(dir, 'ocean-family.js'));
132
+ const stale = await run([...familyArgs, '--check']);
133
+ expect(stale.code).toBe(1);
134
+ expect(stale.stderr).toContain('[fail] 1 theme family output(s)');
135
+ expect(nonAscii(stale)).toEqual([]);
136
+ });
137
+
138
+ it('the suggestion for a quoted glob', async () => {
139
+ const result = await run(['build', 'themes/*.mjs']);
140
+
141
+ expect(result.code).toBe(1);
142
+ expect(result.stderr).toContain('globs are expanded by your shell');
143
+ expect(nonAscii(result)).toEqual([]);
144
+ });
145
+
146
+ it('a palette candidate preview', async () => {
147
+ fs.writeFileSync(
148
+ path.join(dir, 'palette.config.json'),
149
+ JSON.stringify({
150
+ modeStrategy: 'dark-only',
151
+ stops: [10, 50, 90],
152
+ families: [{id: 'blue', seed: '#0074e2'}],
153
+ }),
154
+ );
155
+ const result = await run(['palette', 'generate', 'palette.config.json']);
156
+
157
+ expect(result.code).toBe(0);
158
+ expect(result.stdout).toContain('1 families, 3 stops, dark');
159
+ expect(nonAscii(result)).toEqual([]);
160
+ });
161
+ });
@@ -0,0 +1,110 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Every `theme build` flag interaction is discoverable.
5
+ *
6
+ * Each combination the command refuses is refused before any work, and the
7
+ * refusal is named in the machine-readable manifest's description of at least
8
+ * one of the flags involved. Flags that fill in a value when omitted state
9
+ * their default there too.
10
+ */
11
+
12
+ import {describe, it, expect, beforeAll, afterAll} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import {runCli} from '../../../test-utils/run-cli.mjs';
17
+ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
18
+
19
+ /** @type {string} */
20
+ let dir;
21
+ /** @type {Map<string, string>} flag token -> manifest description */
22
+ let descriptions;
23
+
24
+ beforeAll(async () => {
25
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-theme-flag-docs-'));
26
+ // Refusals happen before a theme loads; the files only need to exist.
27
+ for (const name of ['a.mjs', 'b.mjs']) {
28
+ fs.writeFileSync(path.join(dir, name), 'export default {};\n');
29
+ }
30
+
31
+ const manifest = JSON.parse(
32
+ (await runCli(['--json', 'manifest'], dir)).stdout,
33
+ ).data;
34
+ const theme = manifest.commands.find(command => command.name === 'theme');
35
+ const build = theme.subcommands.find(
36
+ command => command.name === 'theme build',
37
+ );
38
+ descriptions = new Map(
39
+ build.options.map(option => [
40
+ /** @type {string} */ (option.flag.match(/--[a-z-]+/)?.[0]),
41
+ option.description,
42
+ ]),
43
+ );
44
+ });
45
+
46
+ afterAll(() => {
47
+ fs.rmSync(dir, {recursive: true, force: true});
48
+ });
49
+
50
+ /** @param {string} flag */
51
+ function description(flag) {
52
+ const text = descriptions.get(flag);
53
+ expect(text, `manifest has no ${flag} option`).toBeDefined();
54
+ return /** @type {string} */ (text);
55
+ }
56
+
57
+ /**
58
+ * Whether `a`'s description names `b`, or `b`'s names `a`. `b` may also be a
59
+ * pattern for a condition that is not a flag.
60
+ * @param {string} a
61
+ * @param {string | RegExp} b
62
+ */
63
+ function documented(a, b) {
64
+ const partner = typeof b === 'string' ? new RegExp(`${b}\\b`) : b;
65
+ if (partner.test(description(a))) return true;
66
+ return (
67
+ typeof b === 'string' &&
68
+ descriptions.has(b) &&
69
+ new RegExp(`${a}\\b`).test(description(b))
70
+ );
71
+ }
72
+
73
+ const REFUSED = [
74
+ {
75
+ pair: ['--family', '--out'],
76
+ args: ['--family', 'a.mjs', 'b.mjs', '--family-key', 'k', '--out', 'x.css'],
77
+ },
78
+ {
79
+ pair: ['--family', '--watch'],
80
+ args: ['--family', 'a.mjs', 'b.mjs', '--family-key', 'k', '--watch'],
81
+ },
82
+ {pair: ['--family', '--family-key'], args: ['--family', 'a.mjs', 'b.mjs']},
83
+ {pair: ['--family-key', '--family'], args: ['a.mjs', '--family-key', 'k']},
84
+ {pair: ['--check', '--watch'], args: ['a.mjs', '--check', '--watch']},
85
+ {
86
+ pair: ['--out', /several|more than one|single theme|one theme/i],
87
+ args: ['a.mjs', 'b.mjs', '--out', 'x.css'],
88
+ },
89
+ {pair: ['--watch', '--json'], args: ['a.mjs', '--watch']},
90
+ ];
91
+
92
+ describe('theme build flag interactions are documented', () => {
93
+ for (const {pair, args} of REFUSED) {
94
+ const [a, b] = pair;
95
+ it(`${a} with ${String(b)} is refused and documented`, async () => {
96
+ const result = await runCli(['--json', 'theme', 'build', ...args], dir);
97
+
98
+ expect(result.code).toBe(1);
99
+ expect(JSON.parse(result.stdout).code).toBe(
100
+ ERROR_CODES.ERR_THEME_INVALID,
101
+ );
102
+ expect(documented(a, b)).toBe(true);
103
+ });
104
+ }
105
+
106
+ it('flags that fill in an omitted value state their default', () => {
107
+ expect(description('--out')).toMatch(/default/i);
108
+ expect(description('--icons-specifier')).toMatch(/default/i);
109
+ });
110
+ });
@@ -369,7 +369,7 @@ export function registerTheme(program) {
369
369
  emit(
370
370
  section(
371
371
  'Palette candidate',
372
- `${result.data.familyCount} families · ${result.data.stopCount} stops · ${result.data.modes.join(', ')}`,
372
+ `${result.data.familyCount} families, ${result.data.stopCount} stops, ${result.data.modes.join(', ')}`,
373
373
  ),
374
374
  code(serializePaletteCandidate(result.data.candidate).trimEnd()),
375
375
  );
@@ -426,7 +426,7 @@ export function registerTheme(program) {
426
426
  {
427
427
  name: `astryx theme build ${entry.file.replace(/['"]/g, '')}`,
428
428
  reason:
429
- 'globs are expanded by your shell — pass the pattern unquoted, or list the files',
429
+ 'globs are expanded by your shell; pass the pattern unquoted, or list the files',
430
430
  },
431
431
  ]
432
432
  : undefined,
@@ -491,7 +491,7 @@ export function registerTheme(program) {
491
491
  // Non-watch: delegate to the API compiler, once per theme, in argument
492
492
  // order. Enable human output unless in --json mode (log → stdout via
493
493
  // humanLog, warn/error → stderr). The "Building theme from" line, the
494
- // ✓/warning lines, and the install instructions are all emitted from
494
+ // [ok]/[warn] lines, and the install instructions are all emitted from
495
495
  // inside themeBuild via the shared logger.
496
496
  logger.setSilent(json);
497
497
 
@@ -574,8 +574,8 @@ export function registerTheme(program) {
574
574
  emit(
575
575
  text(
576
576
  options.check
577
- ? `\n${stale ? '✗' : '✓'} Checked ${entries.length} themes.`
578
- : `\n✓ Built ${entries.length} themes.`,
577
+ ? `\n${stale ? '[fail]' : '[ok]'} Checked ${entries.length} themes.`
578
+ : `\n[ok] Built ${entries.length} themes.`,
579
579
  ),
580
580
  );
581
581
  }