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

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 (206) hide show
  1. package/README.md +86 -72
  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 +12 -3
  9. package/api/component/component.mjs +31 -2
  10. package/api/component/component.test.mjs +38 -0
  11. package/api/component/component.type.d.mts +16 -5
  12. package/api/component/component.type.mjs +13 -5
  13. package/api/component/detail/blocks/blocks.d.mts +2 -1
  14. package/api/component/detail/blocks/blocks.mjs +4 -3
  15. package/api/component/list/list.d.mts +0 -5
  16. package/api/component/list/list.mjs +37 -9
  17. package/api/discover/discover.doc.mjs +1 -1
  18. package/api/gap-report/gap-report.doc.mjs +7 -4
  19. package/api/init/init.doc.mjs +4 -0
  20. package/api/init/init.test.mjs +41 -1
  21. package/api/init/remove/remove.mjs +1 -1
  22. package/api/init/run/run.mjs +20 -10
  23. package/api/integration/add-contribution.component-names.test.mjs +120 -0
  24. package/api/integration/add-contribution.mjs +11 -0
  25. package/api/integration/add-contribution.test.mjs +80 -0
  26. package/api/integration/integrationAdd.doc.mjs +7 -4
  27. package/api/integration/integrationAddComponent.doc.mjs +1 -1
  28. package/api/integration/validate-integration.mjs +2 -0
  29. package/api/integration/validate-integration.test.mjs +91 -0
  30. package/api/integration/validateIntegration.doc.mjs +1 -1
  31. package/api/json/envelope-types.test.mjs +76 -0
  32. package/api/json/parseResponse.doc.mjs +2 -2
  33. package/api/layout/expand/expand.mjs +7 -5
  34. package/api/layout/expand/expand.path-safety.test.mjs +53 -0
  35. package/api/layout/grammar/grammar.mjs +2 -1
  36. package/api/layout/layoutExpand.doc.mjs +1 -1
  37. package/api/search/search-return-type.test.mjs +54 -0
  38. package/api/search/search.d.mts +2 -9
  39. package/api/search/search.doc.mjs +5 -1
  40. package/api/search/search.mjs +10 -3
  41. package/api/search/search.test.mjs +34 -0
  42. package/api/swizzle/copy/copy.mjs +28 -11
  43. package/api/swizzle/swizzle.doc.mjs +1 -1
  44. package/api/template/copy/copy.mjs +17 -23
  45. package/api/template/copy/copy.test.mjs +17 -0
  46. package/api/template/template.doc.mjs +2 -1
  47. package/api/theme/add/add.binary.test.mjs +91 -0
  48. package/api/theme/add/add.mjs +20 -3
  49. package/api/theme/add/add.staging.test.mjs +66 -0
  50. package/api/theme/build/build.mjs +114 -37
  51. package/api/theme/build/build.public-component-vars.test.mjs +1 -1
  52. package/api/theme/build/build.receipt-doc.test.mjs +111 -0
  53. package/api/theme/build/font-warning.mjs +3 -3
  54. package/api/theme/build/font-warning.test.mjs +5 -2
  55. package/api/theme/palette/generate/generate.mjs +7 -2
  56. package/api/theme/palette/generate/generate.test.mjs +96 -0
  57. package/api/theme/palette/generate/generator.mjs +8 -1
  58. package/api/theme/palette/generate/generator.test.mjs +10 -0
  59. package/api/theme/template/template.mjs +11 -2
  60. package/api/theme/template/template.test.mjs +15 -0
  61. package/api/theme/themeBuild.doc.mjs +7 -4
  62. package/api/theme/themeTemplate.doc.mjs +4 -1
  63. package/api/upgrade/_adapter.mjs +27 -4
  64. package/api/upgrade/list/list.mjs +2 -1
  65. package/api/upgrade/list/list.test.mjs +73 -0
  66. package/api/upgrade/status/status.mjs +2 -2
  67. package/api/upgrade/upgrade.doc.mjs +2 -1
  68. package/api/upgrade/upgrade.type.d.mts +4 -0
  69. package/api/upgrade/upgrade.type.mjs +1 -0
  70. package/assets/codemods/term-log.mjs +32 -8
  71. package/assets/codemods/term-log.test.mjs +19 -1
  72. package/assets/codemods/transform-prop.mjs +109 -0
  73. package/assets/codemods/transform-prop.test.mjs +95 -0
  74. package/assets/codemods/transforms/v0.0.14/__tests__/rename-status-variants.test.mjs +86 -165
  75. package/assets/codemods/transforms/v0.0.14/rename-status-variants.mjs +72 -210
  76. package/assets/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +83 -115
  77. package/assets/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +57 -186
  78. package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
  79. package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
  80. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.doc.mjs +14 -0
  81. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaInlineRail.tsx +61 -0
  82. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.doc.mjs +14 -0
  83. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaOverscrollChaining.tsx +126 -0
  84. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.doc.mjs +15 -0
  85. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaShowcase.tsx +86 -0
  86. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.doc.mjs +14 -0
  87. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyGroupHeaders.tsx +99 -0
  88. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.doc.mjs +14 -0
  89. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaStickyPassthrough.tsx +122 -0
  90. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.doc.mjs +14 -0
  91. package/assets/templates/blocks/components/ScrollableArea/ScrollableAreaTwoAxisBoard.tsx +95 -0
  92. package/assets/templates/pages/table-tree/page.tsx +1704 -0
  93. package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
  94. package/authoring/config/config.doc.mjs +1 -1
  95. package/authoring/config/debug-composition.test.mjs +92 -0
  96. package/authoring/config/type.ts +15 -3
  97. package/clients/cli/command-load-failure.test.mjs +83 -0
  98. package/clients/cli/commands/blog.mjs +23 -8
  99. package/clients/cli/commands/blog.test.mjs +42 -1
  100. package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
  101. package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
  102. package/clients/cli/commands/build-theme.mjs +5 -5
  103. package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
  104. package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
  105. package/clients/cli/commands/build.doc.mjs +4 -1
  106. package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
  107. package/clients/cli/commands/build.mjs +32 -47
  108. package/clients/cli/commands/build.playbook.test.mjs +75 -0
  109. package/clients/cli/commands/build.text-fields.test.mjs +40 -0
  110. package/clients/cli/commands/component/index.mjs +2 -7
  111. package/clients/cli/commands/component-package.test.mjs +46 -0
  112. package/clients/cli/commands/component-resolution.test.mjs +21 -0
  113. package/clients/cli/commands/component.test.mjs +19 -0
  114. package/clients/cli/commands/detail-levels.test.mjs +2 -2
  115. package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
  116. package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
  117. package/clients/cli/commands/discover.doc.mjs +4 -2
  118. package/clients/cli/commands/discover.mjs +3 -3
  119. package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
  120. package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
  121. package/clients/cli/commands/doctor.mjs +6 -16
  122. package/clients/cli/commands/doctor.test.mjs +42 -0
  123. package/clients/cli/commands/gap-report.doc.mjs +16 -5
  124. package/clients/cli/commands/gap-report.test.mjs +72 -0
  125. package/clients/cli/commands/hook/index.mjs +7 -17
  126. package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
  127. package/clients/cli/commands/init.doc.mjs +19 -8
  128. package/clients/cli/commands/init.flag-help.test.mjs +153 -0
  129. package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
  130. package/clients/cli/commands/integration-add.doc.mjs +21 -5
  131. package/clients/cli/commands/interactive-guard.test.mjs +101 -24
  132. package/clients/cli/commands/json-contract.test.mjs +33 -0
  133. package/clients/cli/commands/layout-check.doc.mjs +14 -3
  134. package/clients/cli/commands/layout-expand.doc.mjs +21 -4
  135. package/clients/cli/commands/layout.doc.mjs +2 -2
  136. package/clients/cli/commands/layout.mjs +21 -9
  137. package/clients/cli/commands/layout.path-help.test.mjs +33 -0
  138. package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
  139. package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
  140. package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
  141. package/clients/cli/commands/search.doc.mjs +6 -3
  142. package/clients/cli/commands/search.mjs +17 -7
  143. package/clients/cli/commands/search.test.mjs +75 -0
  144. package/clients/cli/commands/swizzle.doc.mjs +2 -1
  145. package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
  146. package/clients/cli/commands/template.doc.mjs +28 -6
  147. package/clients/cli/commands/template.flag-help.test.mjs +117 -0
  148. package/clients/cli/commands/template.path-help.test.mjs +40 -0
  149. package/clients/cli/commands/theme-add.doc.mjs +2 -1
  150. package/clients/cli/commands/theme-build.doc.mjs +7 -6
  151. package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
  152. package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
  153. package/clients/cli/commands/theme-template.doc.mjs +1 -1
  154. package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +17 -6
  156. package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
  157. package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
  158. package/clients/cli/index.mjs +13 -30
  159. package/clients/cli/latest-version-env.test.mjs +50 -0
  160. package/clients/cli/lib/cli-error.test.mjs +7 -0
  161. package/clients/cli/lib/component-format.mjs +9 -9
  162. package/clients/cli/lib/component-format.test.mjs +1 -1
  163. package/clients/cli/lib/define-command.mjs +32 -6
  164. package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
  165. package/clients/cli/lib/exit-codes.test.mjs +97 -0
  166. package/clients/cli/lib/hook-format.mjs +5 -5
  167. package/clients/cli/lib/json-shim.mjs +38 -2
  168. package/clients/cli/lib/json-shim.test.mjs +83 -0
  169. package/clients/cli/lib/manifest.d.ts +2 -0
  170. package/clients/cli/lib/manifest.mjs +22 -0
  171. package/clients/cli/lib/manifest.test.mjs +17 -0
  172. package/foundation/agent-docs/agent-docs.d.mts +4 -0
  173. package/foundation/agent-docs/agent-docs.mjs +69 -3
  174. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
  175. package/foundation/config/integration-debug.test.mjs +28 -3
  176. package/foundation/config/project.mjs +123 -9
  177. package/foundation/config/project.test.mjs +126 -0
  178. package/foundation/discovery/template-adapter.d.mts +14 -5
  179. package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
  180. package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
  181. package/foundation/discovery/template-adapter.mjs +292 -25
  182. package/foundation/discovery/theming-targets.test.mjs +4 -0
  183. package/foundation/fs/module-loader.d.mts +1 -0
  184. package/foundation/fs/module-loader.mjs +50 -1
  185. package/foundation/fs/module-loader.stdout.test.mjs +332 -0
  186. package/foundation/fs/path-safety.d.mts +3 -2
  187. package/foundation/fs/path-safety.mjs +49 -19
  188. package/foundation/fs/path-safety.test.mjs +50 -0
  189. package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
  190. package/foundation/integrations/autolink.mjs +6 -1
  191. package/foundation/response/base.d.ts +8 -4
  192. package/foundation/response/error-codes.doc.mjs +1 -2
  193. package/foundation/response/error-codes.mjs +1 -1
  194. package/foundation/response/error-codes.test.mjs +83 -0
  195. package/foundation/response/json-contract.test.mjs +11 -0
  196. package/foundation/response/json.d.mts +4 -2
  197. package/foundation/response/json.mjs +8 -10
  198. package/foundation/response/response-types.doc.mjs +10 -10
  199. package/foundation/response/response-types.doc.test.mjs +158 -0
  200. package/foundation/xle/expand.d.mts +2 -0
  201. package/foundation/xle/expand.mjs +3 -2
  202. package/foundation/xle/expand.test.mjs +54 -0
  203. package/package.json +9 -9
  204. package/clients/cli/lib/update-check.mjs +0 -83
  205. package/clients/cli/lib/update-check.test.mjs +0 -137
  206. package/clients/cli/update-hint-commands.test.mjs +0 -54
@@ -35,6 +35,7 @@ import {
35
35
  } from '../../foundation/integrations/integrations.mjs';
36
36
  import {isValidSemver} from '../../foundation/env/semver.mjs';
37
37
  import {assertContributionVisible} from '../../foundation/integrations/contribution-inventory.mjs';
38
+ import {findIntegrationComponentDoc} from '../../foundation/discovery/component-discovery.mjs';
38
39
  import {parseAgentDocsField} from '../../authoring/integration/schema.mjs';
39
40
  import {integrationAddTheme} from './add-theme.mjs';
40
41
  import {
@@ -248,6 +249,16 @@ async function addComponent(name, options) {
248
249
  ERROR_CODES.ERR_FILE_EXISTS,
249
250
  );
250
251
  }
252
+ // Discovery keys components by doc stem anywhere under the root, so a second
253
+ // doc with this name would shadow one of the two.
254
+ const existingDoc = findIntegrationComponentDoc({components: root}, name);
255
+ if (existingDoc != null) {
256
+ throw new AstryxError(
257
+ `Refusing to add component "${name}": ${projectPath(path.relative(packageDir, existingDoc))} already defines it.`,
258
+ undefined,
259
+ ERROR_CODES.ERR_FILE_EXISTS,
260
+ );
261
+ }
251
262
 
252
263
  const sourcePath = projectPath(path.relative(packageDir, sourceFile));
253
264
  const extensionlessPath = sourcePath.replace(/\.tsx?$/u, '');
@@ -731,3 +731,83 @@ describe('public dispatcher', () => {
731
731
  ).rejects.toThrow(/component, doc, template, codemod, agent-doc, or theme/);
732
732
  });
733
733
  });
734
+
735
+ describe('dry-run receipts match the real write', () => {
736
+ /** Package states each writer has to plan for. */
737
+ const STATES = {
738
+ 'no manifest': {pkg: {files: ['dist']}},
739
+ 'an empty manifest': {pkg: {}, manifest: 'export default {};\n'},
740
+ 'declared roots and an exports map': {
741
+ pkg: {files: ['dist'], exports: {'.': './dist/index.js'}},
742
+ manifest:
743
+ "export default {\n components: './components',\n docs: './docs',\n templates: './templates',\n codemods: './codemods',\n themes: './themes',\n};\n",
744
+ },
745
+ };
746
+ /** @type {Array<[any, string, Record<string, unknown>]>} */
747
+ const KINDS = [
748
+ ['component', 'AcmeWidget', {}],
749
+ ['doc', 'deploying', {}],
750
+ ['template', 'account-page', {}],
751
+ ['template', 'account-card', {templateType: 'block'}],
752
+ ['codemod', 'rename-widget', {to: '1.2.0'}],
753
+ ['agent-doc', 'Run acme verify before finishing.', {}],
754
+ ['theme', 'ocean', {}],
755
+ ];
756
+
757
+ /** Every file under the package, keyed by project path, with its bytes. */
758
+ function snapshot() {
759
+ /** @type {Record<string, string>} */
760
+ const files = {};
761
+ /** @param {string} dir */
762
+ const walk = dir => {
763
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
764
+ const full = path.join(dir, entry.name);
765
+ if (entry.isDirectory()) walk(full);
766
+ else {
767
+ files[path.relative(tmpDir, full).split(path.sep).join('/')] =
768
+ fs.readFileSync(full, 'base64');
769
+ }
770
+ }
771
+ };
772
+ walk(tmpDir);
773
+ return files;
774
+ }
775
+
776
+ for (const [stateName, state] of Object.entries(STATES)) {
777
+ for (const [kind, name, options] of KINDS) {
778
+ it(`${kind} ${name} in a package with ${stateName}`, async () => {
779
+ fs.writeFileSync(
780
+ path.join(tmpDir, 'package.json'),
781
+ `${JSON.stringify({name: '@acme/integration', version: '1.0.0', ...state.pkg}, null, 2)}\n`,
782
+ );
783
+ if (state.manifest != null) {
784
+ fs.writeFileSync(
785
+ path.join(tmpDir, 'astryx.integration.mjs'),
786
+ state.manifest,
787
+ );
788
+ }
789
+ const before = snapshot();
790
+
791
+ const plan = await integrationAdd(kind, name, {
792
+ cwd: tmpDir,
793
+ dryRun: true,
794
+ ...options,
795
+ });
796
+ expect(snapshot()).toEqual(before);
797
+
798
+ const written = await integrationAdd(kind, name, {
799
+ cwd: tmpDir,
800
+ ...options,
801
+ });
802
+ const after = snapshot();
803
+ const changed = Object.keys(after)
804
+ .filter(file => after[file] !== before[file])
805
+ .sort();
806
+
807
+ expect(written.data.root).toEqual(plan.data.root);
808
+ expect(written.data.files).toEqual(plan.data.files);
809
+ expect([...written.data.files].sort()).toEqual(changed);
810
+ });
811
+ }
812
+ }
813
+ });
@@ -33,7 +33,8 @@ export const doc = {
33
33
  {
34
34
  name: 'name',
35
35
  type: 'string',
36
- description: 'Contribution name, or the literal agent-doc line.',
36
+ description:
37
+ 'Contribution name: PascalCase for component, lowercase kebab-case for template, codemod, and theme, letters, digits, _ and - for doc, or the literal agent-doc line.',
37
38
  required: true,
38
39
  },
39
40
  {
@@ -56,17 +57,19 @@ export const doc = {
56
57
  {
57
58
  name: 'options.replaces',
58
59
  type: 'string',
59
- description: 'Existing topic to replace. Only valid for doc.',
60
+ description:
61
+ 'Existing topic to replace. Only valid for doc, and not with options.extends.',
60
62
  },
61
63
  {
62
64
  name: 'options.extends',
63
65
  type: 'string',
64
- description: 'Existing topic to extend. Only valid for doc.',
66
+ description:
67
+ 'Existing topic to extend. Only valid for doc, and not with options.replaces.',
65
68
  },
66
69
  {
67
70
  name: 'options.to',
68
71
  type: 'string',
69
- description: 'Exact target semver. Required for codemod.',
72
+ description: 'Exact target semver (e.g. 1.2.0). Required for codemod and only valid there.',
70
73
  },
71
74
  ],
72
75
  returns: [
@@ -8,7 +8,7 @@ export const doc = {
8
8
  displayName: 'integrationAddComponent()',
9
9
  summary: 'Add a complete component contribution.',
10
10
  description:
11
- 'Creates the same-stem component source and metadata pair, declares the components root on first use, preserves existing package allowlists, and publishes an exact source subpath when the package already has an exports map.',
11
+ 'Creates the same-stem component source and metadata pair, declares the components root on first use, preserves existing package allowlists, and publishes an exact source subpath when the package already has an exports map. Refuses with ERR_FILE_EXISTS when a component doc anywhere under the components root already uses the name.',
12
12
  importPath: '@astryxdesign/cli/api',
13
13
  signature:
14
14
  'integrationAddComponent(name: string, options?: IntegrationAddComponentOptions): Promise<IntegrationAddResponse>',
@@ -400,8 +400,10 @@ export async function validateLocalIntegration(cwd = process.cwd()) {
400
400
  try {
401
401
  pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
402
402
  } catch (err) {
403
+ // A null name means "no manifest here"; this package has one.
403
404
  return {
404
405
  found: true,
406
+ name: '(local package)',
405
407
  manifestFile: manifests[0],
406
408
  issues: [
407
409
  error(
@@ -13,10 +13,16 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import {
16
+ validateIntegration,
16
17
  validateLocalIntegration,
17
18
  validateInstalledIntegration,
18
19
  summarizeIssues,
19
20
  } from './validate-integration.mjs';
21
+ import {
22
+ integrationComponentConflicts,
23
+ integrationDocConflicts,
24
+ integrationTemplateConflicts,
25
+ } from './authoring-checks.mjs';
20
26
 
21
27
  let tmpDir;
22
28
 
@@ -472,3 +478,88 @@ describe('validate-integration API', () => {
472
478
  expect(byCode(result.issues, 'invalid_package_spec')).toHaveLength(1);
473
479
  });
474
480
  });
481
+
482
+ describe('integration diagnostics are read-only', () => {
483
+ /** Every directory and file under `dir`, with file bytes. */
484
+ function snapshot(dir) {
485
+ /** @type {Record<string, string>} */
486
+ const entries = {};
487
+ /** @param {string} current */
488
+ const walk = current => {
489
+ for (const entry of fs.readdirSync(current, {withFileTypes: true})) {
490
+ const full = path.join(current, entry.name);
491
+ const key = path.relative(dir, full);
492
+ if (entry.isDirectory()) {
493
+ entries[`${key}/`] = '';
494
+ walk(full);
495
+ } else {
496
+ entries[key] = fs.readFileSync(full, 'base64');
497
+ }
498
+ }
499
+ };
500
+ walk(dir);
501
+ return entries;
502
+ }
503
+
504
+ /** A package with valid, broken, conflicting, and unreachable contributions. */
505
+ function writeDiagnosedPackage(dir) {
506
+ writePackage(dir, {
507
+ manifest:
508
+ "export default {\n components: './components',\n templates: './templates',\n docs: './docs',\n codemods: './gone',\n themes: './themes',\n};\n",
509
+ });
510
+ const write = (relative, contents) => {
511
+ fs.mkdirSync(path.dirname(path.join(dir, relative)), {recursive: true});
512
+ fs.writeFileSync(path.join(dir, relative), contents);
513
+ };
514
+ write(
515
+ 'components/Card.doc.mjs',
516
+ "export default {type: 'component', name: 'Card', props: []};\n",
517
+ );
518
+ write('components/Card.tsx', 'export function Card() { return null; }\n');
519
+ write(
520
+ 'components/Orphan.doc.mjs',
521
+ "export default {type: 'component', name: 'Orphan', props: []};\n",
522
+ );
523
+ write('templates/dash.doc.mjs', "export default {type: 'page', name: 'Dash'};\n");
524
+ write(
525
+ 'docs/theme.doc.mjs',
526
+ "export default {type: 'generic', name: 'theme', title: 'Theme', description: 'x', sections: [{title: 'A', content: [{type: 'prose', text: 'x'}]}]};\n",
527
+ );
528
+ write('themes/manifest.json', '{"version": 1, "themes": [{"slug": 7}]}\n');
529
+ write(
530
+ 'stray/Stray.doc.mjs',
531
+ "export default {type: 'component', name: 'Stray', props: []};\n",
532
+ );
533
+ }
534
+
535
+ it('leaves local and installed packages byte-for-byte unchanged', async () => {
536
+ const pkgDir = path.join(tmpDir, 'pkg');
537
+ writeDiagnosedPackage(pkgDir);
538
+ const consumer = path.join(tmpDir, 'consumer');
539
+ fs.mkdirSync(consumer, {recursive: true});
540
+ fs.writeFileSync(
541
+ path.join(consumer, 'package.json'),
542
+ JSON.stringify({name: 'consumer'}),
543
+ );
544
+ writeDiagnosedPackage(path.join(consumer, 'node_modules', '@acme', 'widgets'));
545
+ const before = {pkg: snapshot(pkgDir), consumer: snapshot(consumer)};
546
+
547
+ const local = await validateIntegration(undefined, {cwd: pkgDir});
548
+ const installed = await validateIntegration('@acme/widgets', {
549
+ cwd: consumer,
550
+ });
551
+ for (const [pkg, cwd] of [
552
+ [undefined, pkgDir],
553
+ ['@acme/widgets', consumer],
554
+ ]) {
555
+ await integrationTemplateConflicts(pkg, {cwd});
556
+ await integrationComponentConflicts(pkg, {cwd});
557
+ await integrationDocConflicts(pkg, {cwd});
558
+ }
559
+
560
+ expect(summarizeIssues(local.data.issues).errors).toBeGreaterThan(0);
561
+ expect(byCode(local.data.issues, 'unreachable_contribution')).toHaveLength(1);
562
+ expect(summarizeIssues(installed.data.issues).errors).toBeGreaterThan(0);
563
+ expect({pkg: snapshot(pkgDir), consumer: snapshot(consumer)}).toEqual(before);
564
+ });
565
+ });
@@ -45,7 +45,7 @@ export const doc = {
45
45
  {
46
46
  type: 'integration.validate',
47
47
  description:
48
- 'The result envelope: `data.name` and `data.version` of the validated package (both null when no local manifest is found), plus `data.issues`, an AstryxIntegrationIssue[] of {code, severity: `warning` | `error`, message}.',
48
+ 'The result envelope: `data.name` and `data.version` of the validated package (both null only when no local manifest is found; `data.name` is `(local package)` when the local package.json has no readable name), plus `data.issues`, an AstryxIntegrationIssue[] of {code, severity: `warning` | `error`, message}.',
49
49
  },
50
50
  ],
51
51
  examples: [
@@ -0,0 +1,76 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The published `@astryxdesign/cli/json` envelope types carry
5
+ * `apiVersion`, the field every `--json` envelope has. Type-checks a consumer
6
+ * fixture that reads it from each envelope shape.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import {spawnSync} from 'node:child_process';
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import {createRequire} from 'node:module';
15
+ import {fileURLToPath} from 'node:url';
16
+
17
+ const INDEX = fileURLToPath(new URL('./index.ts', import.meta.url));
18
+ const TSC = createRequire(import.meta.url).resolve('typescript/bin/tsc');
19
+
20
+ const FIXTURE = `
21
+ import type {
22
+ assertResponse,
23
+ CLIError,
24
+ CLIUnsupportedError,
25
+ parseResponse,
26
+ } from ${JSON.stringify(INDEX)};
27
+
28
+ declare const error: CLIError;
29
+ declare const unsupported: CLIUnsupportedError;
30
+ declare const parsed: Exclude<ReturnType<typeof parseResponse>, CLIError | CLIUnsupportedError>;
31
+ declare const asserted: ReturnType<typeof assertResponse<'manifest'>>;
32
+
33
+ export const versions: number[] = [
34
+ error.apiVersion,
35
+ unsupported.apiVersion,
36
+ parsed.apiVersion,
37
+ asserted.apiVersion,
38
+ ];
39
+ `;
40
+
41
+ describe('@astryxdesign/cli/json envelope types', () => {
42
+ it('declare apiVersion on the success and error envelopes', () => {
43
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-envelope-types-'));
44
+ try {
45
+ fs.writeFileSync(path.join(dir, 'fixture.ts'), FIXTURE);
46
+ fs.writeFileSync(
47
+ path.join(dir, 'tsconfig.json'),
48
+ JSON.stringify({
49
+ compilerOptions: {
50
+ target: 'ES2022',
51
+ module: 'ESNext',
52
+ moduleResolution: 'bundler',
53
+ strict: true,
54
+ noEmit: true,
55
+ skipLibCheck: true,
56
+ allowImportingTsExtensions: true,
57
+ // index.ts re-exports JSDoc types from .type.mjs files; read them
58
+ // from source, as tsconfig.json-api.json does, not from generated
59
+ // declarations that exist only after a build.
60
+ allowJs: true,
61
+ types: [],
62
+ },
63
+ files: ['fixture.ts'],
64
+ }),
65
+ );
66
+ const res = spawnSync(process.execPath, [TSC, '-p', dir], {
67
+ encoding: 'utf-8',
68
+ timeout: 60_000,
69
+ });
70
+ expect(res.stdout + res.stderr).toBe('');
71
+ expect(res.status).toBe(0);
72
+ } finally {
73
+ fs.rmSync(dir, {recursive: true, force: true});
74
+ }
75
+ });
76
+ });
@@ -16,7 +16,7 @@ export const doc = {
16
16
  description:
17
17
  'The entry point for consuming the CLI as a subprocess. A JSON string is parsed; ' +
18
18
  'an already-parsed object is passed through unchanged, so it is safe to call on ' +
19
- 'either. It returns the structural { type, data, meta? } envelope without narrowing: ' +
19
+ 'either. It returns the structural { apiVersion, type, data, meta? } envelope without narrowing: ' +
20
20
  '`data` stays unknown until you discriminate on `type` (or use assertResponse).',
21
21
  importPath: '@astryxdesign/cli/json',
22
22
  signature: 'parseResponse(raw: unknown): any',
@@ -34,7 +34,7 @@ export const doc = {
34
34
  {
35
35
  type: 'any',
36
36
  description:
37
- 'The { type, data, meta? } envelope, or a CLIError envelope. The published signature is intentionally untyped; narrow it by casting to the matching *Response type exported from @astryxdesign/cli/json.',
37
+ 'The { apiVersion, type, data, meta? } envelope, or a CLIError envelope ({ apiVersion, error, code, suggestions? }). The published signature is intentionally untyped; narrow it by casting to the matching *Response type exported from @astryxdesign/cli/json.',
38
38
  },
39
39
  ],
40
40
  throws: [
@@ -120,18 +120,20 @@ export async function layoutExpand(expression, options = {}) {
120
120
 
121
121
  let written = null;
122
122
  if (targetPath) {
123
- let resolved;
123
+ // The guard sees the file that will be written, not only its directory:
124
+ // a symlink at that name would otherwise carry the write outside the root.
125
+ const fileTarget = isFilePathArg(targetPath)
126
+ ? targetPath
127
+ : path.join(targetPath, `${componentName}.tsx`);
128
+ let filePath;
124
129
  try {
125
- resolved = assertWithin(targetPath, cwd, {label: 'layout target path'});
130
+ filePath = assertWithin(fileTarget, cwd, {label: 'layout target path'});
126
131
  } catch (err) {
127
132
  if (err instanceof PathSafetyError) {
128
133
  throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
129
134
  }
130
135
  throw err;
131
136
  }
132
- const filePath = isFilePathArg(targetPath)
133
- ? resolved
134
- : path.join(resolved, `${componentName}.tsx`);
135
137
  fs.mkdirSync(path.dirname(filePath), {recursive: true});
136
138
  fs.writeFileSync(filePath, result.code);
137
139
  written = path.relative(cwd, filePath);
@@ -0,0 +1,53 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Path-safety tests for the `layout.expand` write. The file written, not
5
+ * only the directory it lands in, must stay inside the project root.
6
+ */
7
+
8
+ import {describe, it, expect, beforeAll} from 'vitest';
9
+ import * as fs from 'node:fs';
10
+ import * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ import {layoutExpand} from '../layout.mjs';
13
+ import {buildRegistry} from '../../../foundation/xle/registry.mjs';
14
+
15
+ const SLOW = 30_000;
16
+
17
+ // The registry imports every component doc on first use; warm it once.
18
+ beforeAll(async () => {
19
+ await buildRegistry();
20
+ }, 120_000);
21
+
22
+ describe('layout.expand — write confinement', () => {
23
+ it('rejects a directory target whose generated file is a symlink leading outside cwd', async () => {
24
+ // Inside the workspace so @astryxdesign/core resolves.
25
+ const cwd = fs.mkdtempSync(path.join(process.cwd(), '.xle-confine-test-'));
26
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'xle-confine-outside-'));
27
+ try {
28
+ const victim = path.join(outside, 'victim.tsx');
29
+ fs.writeFileSync(victim, 'OUTSIDE');
30
+ fs.mkdirSync(path.join(cwd, 'out'));
31
+ fs.symlinkSync(victim, path.join(cwd, 'out', 'GeneratedLayout.tsx'));
32
+
33
+ await expect(
34
+ layoutExpand('V > B', {targetPath: './out', cwd}),
35
+ ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
36
+ expect(fs.readFileSync(victim, 'utf-8')).toBe('OUTSIDE');
37
+ } finally {
38
+ fs.rmSync(cwd, {recursive: true, force: true});
39
+ fs.rmSync(outside, {recursive: true, force: true});
40
+ }
41
+ }, SLOW);
42
+
43
+ it('still writes <Name>.tsx into a plain directory target', async () => {
44
+ const cwd = fs.mkdtempSync(path.join(process.cwd(), '.xle-confine-test-'));
45
+ try {
46
+ const result = await layoutExpand('V > B', {targetPath: './out', name: 'Demo', cwd});
47
+ expect(result.data.written).toBe(path.join('out', 'Demo.tsx'));
48
+ expect(fs.existsSync(path.join(cwd, 'out', 'Demo.tsx'))).toBe(true);
49
+ } finally {
50
+ fs.rmSync(cwd, {recursive: true, force: true});
51
+ }
52
+ }, SLOW);
53
+ });
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import {buildRegistry} from '../../../foundation/xle/registry.mjs';
13
+ import {MAX_REPEAT} from '../../../foundation/xle/expand.mjs';
13
14
 
14
15
  /**
15
16
  * `astryx layout grammar` — the agent cheatsheet, with the alias table
@@ -50,7 +51,7 @@ NODE ANATOMY Name#id.enum"payload"[attrs]{hint}*N > children
50
51
  .enum unique enum value of any prop: Bd.success Tx.lg B.primary
51
52
  "payload" primary text prop (label/title/heading) or text child: TI"Email" B"Save"
52
53
  {hint} kebab-case template/component reference (see TEMPLATE REFERENCING) — NEVER text
53
- *N / xN repeat (use $ for the counter: Tk"item-$"*3)
54
+ *N / xN repeat, at most ${MAX_REPEAT} copies (use $ for the counter: Tk"item-$"*3)
54
55
  trailing ! initial selection for scaffolded state: Tab"Overview"!
55
56
 
56
57
  ATTRS [...] (outline: bare tokens after the name, no brackets)
@@ -36,7 +36,7 @@ export const doc = {
36
36
  name: 'options.targetPath',
37
37
  type: 'string',
38
38
  description:
39
- 'Write the generated TSX here (validated to stay within cwd). A directory gets <name>.tsx; a file path is used as-is. Omit to return the code without writing.',
39
+ 'Write the generated TSX here (validated to stay within cwd). A path that ends in .tsx, .ts, .jsx, .js, .mjs, .cjs, .css, .scss, .json, .md or .html is used as-is; any other path is a directory that gets <name>.tsx. An existing file is replaced. Omit to return the code without writing.',
40
40
  },
41
41
  {
42
42
  name: 'options.form',
@@ -0,0 +1,54 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The declared return type of `search()` is the canonical SearchResponse.
5
+ * Consumers of `@astryxdesign/cli/api` type-check against it; an `object` entry
6
+ * type erases every result field from their view.
7
+ */
8
+
9
+ import {createRequire} from 'node:module';
10
+ import * as path from 'node:path';
11
+ import {fileURLToPath} from 'node:url';
12
+ import {describe, expect, it} from 'vitest';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ts = require('typescript');
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const SEARCH = path.join(HERE, 'search.mjs');
19
+ const TYPES = path.join(HERE, 'search.type.mjs');
20
+
21
+ describe('search() declared return type', () => {
22
+ it('types each result as the canonical SearchResultEntry', () => {
23
+ const program = ts.createProgram([SEARCH, TYPES], {
24
+ allowJs: true,
25
+ noEmit: true,
26
+ skipLibCheck: true,
27
+ types: [],
28
+ module: ts.ModuleKind.NodeNext,
29
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
30
+ target: ts.ScriptTarget.ES2022,
31
+ });
32
+ const checker = program.getTypeChecker();
33
+ const source = program.getSourceFile(SEARCH);
34
+ /** @param {string} file @param {string} name */
35
+ const exportOf = (file, name) =>
36
+ checker
37
+ .getExportsOfModule(checker.getSymbolAtLocation(program.getSourceFile(file)))
38
+ .find((/** @type {any} */ s) => s.name === name);
39
+ /** @param {any} type @param {string} name */
40
+ const prop = (type, name) =>
41
+ checker.getTypeOfSymbolAtLocation(type.getProperty(name), source);
42
+ /** @param {any} type */
43
+ const names = type => type.getProperties().map((/** @type {any} */ p) => p.name).sort();
44
+
45
+ const fn = checker.getTypeOfSymbolAtLocation(exportOf(SEARCH, 'search'), source);
46
+ const response = checker.getAwaitedType(fn.getCallSignatures()[0].getReturnType());
47
+ const data = prop(response, 'data');
48
+ expect(names(data)).toEqual(['matchCount', 'query', 'results']);
49
+ const entry = checker.getIndexTypeOfType(prop(data, 'results'), ts.IndexKind.Number);
50
+ const canonical = checker.getDeclaredTypeOfSymbol(exportOf(TYPES, 'SearchResultEntry'));
51
+ expect(names(canonical)).toContain('command');
52
+ expect(names(entry)).toEqual(names(canonical));
53
+ }, 30_000);
54
+ });
@@ -68,20 +68,13 @@ export function scoreCandidate(term: string, { name, keywords, weakKeywords, des
68
68
  * @param {string} [options.cwd]
69
69
  * @param {'component'|'hook'|'doc'|'template'} [options.type] - Restrict to one domain.
70
70
  * @param {number} [options.limit] - Max results (default 20).
71
- * @returns {Promise<{type: 'search', data: {query: string, matchCount: number, results: Array<object>}}>}
71
+ * @returns {Promise<import('./search.type.mjs').SearchResponse>}
72
72
  */
73
73
  export function search(query: string, options?: {
74
74
  cwd?: string | undefined;
75
75
  type?: "component" | "hook" | "doc" | "template" | undefined;
76
76
  limit?: number | undefined;
77
- }): Promise<{
78
- type: "search";
79
- data: {
80
- query: string;
81
- matchCount: number;
82
- results: Array<object>;
83
- };
84
- }>;
77
+ }): Promise<import("./search.type.mjs").SearchResponse>;
85
78
  /** Valid domain filters for `--type`. */
86
79
  export const SEARCH_DOMAINS: string[];
87
80
  /**
@@ -53,7 +53,7 @@ export const doc = {
53
53
  {
54
54
  type: 'search',
55
55
  description:
56
- 'The query echoed back, `matchCount` (how many candidates matched in total, before `limit`), plus a ranked SearchResultEntry[] bounded by `limit` (domain, name, score, reason, description, follow-up command, and import path where relevant).',
56
+ 'The query echoed back, `matchCount` (how many candidates matched in total, before `limit`), plus a ranked SearchResultEntry[] bounded by `limit`: each has domain, name, score, reason, description, and follow-up command, plus `import` for components and hooks, `title` for docs, and `displayName` and `kind` for templates.',
57
57
  },
58
58
  ],
59
59
  throws: [
@@ -61,6 +61,10 @@ export const doc = {
61
61
  code: 'ERR_INVALID_ARGUMENT',
62
62
  when: 'the query is empty, --type is unknown, or --limit is not a positive integer',
63
63
  },
64
+ {
65
+ code: 'ERR_CORE_NOT_FOUND',
66
+ when: '@astryxdesign/core cannot be found from the cwd',
67
+ },
64
68
  ],
65
69
  examples: [
66
70
  {label: 'Find a component', code: "const r = await search('button');"},
@@ -884,7 +884,7 @@ function toResult(c, score, reason, matchedTerms, queryTerms) {
884
884
  * @param {string} [options.cwd]
885
885
  * @param {'component'|'hook'|'doc'|'template'} [options.type] - Restrict to one domain.
886
886
  * @param {number} [options.limit] - Max results (default 20).
887
- * @returns {Promise<{type: 'search', data: {query: string, matchCount: number, results: Array<object>}}>}
887
+ * @returns {Promise<import('./search.type.mjs').SearchResponse>}
888
888
  */
889
889
  export async function search(query, options = {}) {
890
890
  const {cwd = process.cwd(), type, limit = 20} = options;
@@ -921,7 +921,11 @@ export async function search(query, options = {}) {
921
921
 
922
922
  const coreDir = findCoreDir(cwd);
923
923
  if (!coreDir) {
924
- throw new AstryxError('Could not find @astryxdesign/core package');
924
+ throw new AstryxError(
925
+ 'Could not find @astryxdesign/core package',
926
+ undefined,
927
+ ERROR_CODES.ERR_CORE_NOT_FOUND,
928
+ );
925
929
  }
926
930
 
927
931
  // Gather candidates from each requested domain in parallel.
@@ -971,7 +975,10 @@ export async function search(query, options = {}) {
971
975
  data: {
972
976
  query: String(query).trim(),
973
977
  matchCount: scored.length,
974
- results: limited,
978
+ // toResult gives every domain its command and domain fields.
979
+ results: /** @type {import('./search.type.mjs').SearchResultEntry[]} */ (
980
+ limited
981
+ ),
975
982
  },
976
983
  };
977
984
  }