@astryxdesign/cli 0.1.0 → 0.1.1-canary.080d887

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 (176) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +117 -75
  3. package/bin/astryx.mjs +22 -7
  4. package/docs/getting-started.doc.mjs +11 -11
  5. package/docs/icons.doc.mjs +1 -1
  6. package/docs/migration.doc.mjs +2 -2
  7. package/docs/shape.doc.mjs +1 -1
  8. package/docs/styling.doc.mjs +3 -4
  9. package/docs/theme.doc.dense.mjs +2 -2
  10. package/docs/theme.doc.mjs +14 -0
  11. package/docs/theme.doc.zh.mjs +2 -2
  12. package/docs/working-with-ai.doc.mjs +4 -4
  13. package/package.json +20 -9
  14. package/src/api/discover.mjs +78 -26
  15. package/src/api/doctor.mjs +3 -3
  16. package/src/api/layout.mjs +301 -0
  17. package/src/api/layout.test.mjs +238 -0
  18. package/src/api/search.mjs +207 -13
  19. package/src/api/template.mjs +193 -51
  20. package/src/api/template.test.mjs +2 -0
  21. package/src/codemods/__tests__/registry.test.mjs +1 -0
  22. package/src/codemods/registry.mjs +1 -0
  23. package/src/codemods/runner.mjs +105 -51
  24. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +116 -0
  25. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +51 -0
  26. package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
  27. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +230 -0
  28. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +84 -0
  29. package/src/commands/agent-docs.mjs +119 -66
  30. package/src/commands/agent-docs.path-safety.test.mjs +1 -1
  31. package/src/commands/agent-docs.test.mjs +87 -31
  32. package/src/commands/build-theme.import-path.test.mjs +1 -1
  33. package/src/commands/build-theme.path-safety.test.mjs +1 -1
  34. package/src/commands/build-theme.prose.test.mjs +1 -1
  35. package/src/commands/build.mjs +196 -0
  36. package/src/commands/component-package.test.mjs +1 -1
  37. package/src/commands/component.test.mjs +1 -1
  38. package/src/commands/docs.test.mjs +1 -1
  39. package/src/commands/doctor.test.mjs +1 -1
  40. package/src/commands/external-showcase.test.mjs +1 -1
  41. package/src/commands/gap-report.mjs +17 -9
  42. package/src/commands/gap-report.test.mjs +21 -16
  43. package/src/commands/init.mjs +43 -9
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/interactive-guard.test.mjs +1 -1
  46. package/src/commands/json-contract.test.mjs +10 -3
  47. package/src/commands/layout.mjs +139 -0
  48. package/src/commands/swizzle-gap-safety.test.mjs +1 -1
  49. package/src/commands/swizzle.mjs +51 -23
  50. package/src/commands/swizzle.path-safety.test.mjs +1 -1
  51. package/src/commands/template.path-safety.test.mjs +1 -1
  52. package/src/commands/template.test.mjs +1 -1
  53. package/src/commands/upgrade.mjs +292 -177
  54. package/src/commands/upgrade.test.mjs +41 -27
  55. package/src/config.mjs +31 -0
  56. package/src/config.test.mjs +24 -0
  57. package/src/index.mjs +5 -0
  58. package/src/lib/config-schema.mjs +119 -0
  59. package/src/lib/config.mjs +45 -6
  60. package/src/lib/config.test.mjs +91 -0
  61. package/src/lib/error-codes.mjs +11 -0
  62. package/src/lib/integrations.mjs +155 -0
  63. package/src/lib/integrations.test.mjs +154 -0
  64. package/src/lib/levenshtein.mjs +29 -0
  65. package/src/lib/manifest.mjs +6 -0
  66. package/src/lib/package-scanner.mjs +31 -7
  67. package/src/lib/string-utils.mjs +5 -14
  68. package/src/lib/xle/browser.d.ts +91 -0
  69. package/src/lib/xle/browser.mjs +120 -0
  70. package/src/lib/xle/expand.mjs +622 -0
  71. package/src/lib/xle/parse.mjs +581 -0
  72. package/src/lib/xle/print.mjs +174 -0
  73. package/src/lib/xle/registry-core.mjs +170 -0
  74. package/src/lib/xle/registry.mjs +237 -0
  75. package/src/lib/xle/splice.mjs +137 -0
  76. package/src/lib/xle/validate.mjs +356 -0
  77. package/src/lib/xle/xle.test.mjs +333 -0
  78. package/src/types/config.d.ts +99 -0
  79. package/src/types/error-codes.d.ts +1 -0
  80. package/src/utils/github.mjs +12 -27
  81. package/src/utils/interactive.mjs +1 -1
  82. package/src/utils/interactive.test.mjs +2 -0
  83. package/src/utils/package-manager.mjs +1 -1
  84. package/src/utils/package-manager.test.mjs +1 -1
  85. package/src/utils/path-safety.test.mjs +1 -1
  86. package/src/utils/paths.test.mjs +8 -8
  87. package/src/utils/update-check.mjs +4 -26
  88. package/src/utils/update-check.test.mjs +2 -64
  89. package/templates/blocks/components/AppShell/AppShellContentOnly.tsx +1 -9
  90. package/templates/blocks/components/AppShell/AppShellShowcase.tsx +1 -10
  91. package/templates/blocks/components/AppShell/AppShellSideNavOnly.tsx +1 -9
  92. package/templates/blocks/components/AppShell/AppShellTopNavOnly.tsx +1 -9
  93. package/templates/blocks/components/AppShell/AppShellTopNavWithSideNav.tsx +1 -9
  94. package/templates/blocks/components/AppShell/AppShellWithBanner.tsx +1 -9
  95. package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +12 -19
  96. package/templates/blocks/components/Banner/BannerShowcase.tsx +1 -8
  97. package/templates/blocks/components/Blockquote/BlockquoteShowcase.tsx +1 -8
  98. package/templates/blocks/components/Carousel/CarouselShowcase.tsx +2 -12
  99. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerShowcase.tsx +6 -9
  100. package/templates/blocks/components/ChatLayout/ChatLayoutPanelChat.tsx +10 -12
  101. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +1 -9
  102. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -9
  103. package/templates/blocks/components/ChatMessageList/ChatMessageListShowcase.tsx +1 -9
  104. package/templates/blocks/components/ChatMessageMetadata/ChatMessageMetadataShowcase.tsx +1 -8
  105. package/templates/blocks/components/ChatSendButton/ChatSendButtonInComposer.tsx +1 -8
  106. package/templates/blocks/components/Citation/CitationInlineText.tsx +4 -4
  107. package/templates/blocks/components/Code/CodeInlineInParagraph.tsx +1 -8
  108. package/templates/blocks/components/CodeBlock/CodeBlockBashCommand.tsx +1 -1
  109. package/templates/blocks/components/CodeBlock/CodeBlockJSONConfig.tsx +1 -1
  110. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  111. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  112. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +9 -12
  113. package/templates/blocks/components/ContextMenu/ContextMenuShowcase.tsx +13 -15
  114. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  115. package/templates/blocks/components/Divider/DividerShowcase.tsx +1 -8
  116. package/templates/blocks/components/Divider/DividerVertical.tsx +7 -9
  117. package/templates/blocks/components/Field/FieldShowcase.tsx +1 -8
  118. package/templates/blocks/components/FormLayout/FormLayoutHorizontal.tsx +1 -6
  119. package/templates/blocks/components/Grid/GridResponsiveAutoFit.tsx +1 -9
  120. package/templates/blocks/components/HoverCard/HoverCardInlineTextHoverCard.tsx +4 -6
  121. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +1 -6
  122. package/templates/blocks/components/HoverCard/HoverCardProfileHoverCard.tsx +2 -8
  123. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -8
  124. package/templates/blocks/components/OverflowList/OverflowListOverflowBadges.tsx +8 -11
  125. package/templates/blocks/components/OverflowList/OverflowListOverflowDropdownActions.tsx +9 -12
  126. package/templates/blocks/components/Overlay/OverlayBottomStrip.tsx +4 -17
  127. package/templates/blocks/components/Overlay/OverlayHoverReveal.tsx +15 -16
  128. package/templates/blocks/components/Overlay/OverlayShowcase.tsx +5 -21
  129. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -14
  130. package/templates/blocks/components/Pagination/PaginationPageSize.tsx +12 -14
  131. package/templates/blocks/components/Pagination/PaginationVariants.tsx +1 -8
  132. package/templates/blocks/components/Pagination/PaginationWithTable.tsx +2 -14
  133. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  134. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  135. package/templates/blocks/components/Tokenizer/TokenizerClear.tsx +1 -6
  136. package/templates/blocks/components/Tokenizer/TokenizerCreatable.tsx +2 -7
  137. package/templates/blocks/components/Tokenizer/TokenizerEndContent.tsx +1 -6
  138. package/templates/blocks/components/Tokenizer/TokenizerIcon.tsx +1 -6
  139. package/templates/blocks/components/Tokenizer/TokenizerMaxEntries.tsx +1 -6
  140. package/templates/blocks/components/Tokenizer/TokenizerOverflow.tsx +2 -7
  141. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +1 -6
  142. package/templates/blocks/components/Tokenizer/TokenizerStates.tsx +4 -9
  143. package/templates/blocks/components/Toolbar/ToolbarCardHeader.tsx +1 -10
  144. package/templates/blocks/components/Toolbar/ToolbarSizes.tsx +1 -8
  145. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +1 -8
  146. package/templates/blocks/components/Toolbar/ToolbarThreeSlot.tsx +1 -10
  147. package/templates/blocks/components/Toolbar/ToolbarWithTabs.tsx +8 -11
  148. package/templates/pages/ai-chat/page.tsx +71 -64
  149. package/templates/pages/ai-chat-landing/page.tsx +8 -12
  150. package/templates/pages/centered-hero/page.tsx +13 -15
  151. package/templates/pages/classic-gallery/page.tsx +27 -34
  152. package/templates/pages/detail-page/page.tsx +18 -18
  153. package/templates/pages/documentation/page.tsx +42 -58
  154. package/templates/pages/documentation-design/page.tsx +82 -60
  155. package/templates/pages/documentation-technical/page.tsx +101 -60
  156. package/templates/pages/editor/page.tsx +42 -54
  157. package/templates/pages/file-explorer/page.tsx +13 -16
  158. package/templates/pages/form-two-column/page.tsx +13 -17
  159. package/templates/pages/gallery-hero/page.tsx +13 -15
  160. package/templates/pages/ide/page.tsx +188 -264
  161. package/templates/pages/library/page.tsx +16 -23
  162. package/templates/pages/login/page.tsx +14 -18
  163. package/templates/pages/login-card/page.tsx +14 -18
  164. package/templates/pages/login-split/page.tsx +50 -48
  165. package/templates/pages/login-sso/page.tsx +9 -13
  166. package/templates/pages/mixed-gallery/page.tsx +51 -45
  167. package/templates/pages/payment-form/page.tsx +56 -70
  168. package/templates/pages/product-detail/page.tsx +27 -33
  169. package/templates/pages/product-gallery/page.tsx +7 -13
  170. package/templates/pages/settings-dialog/page.tsx +35 -43
  171. package/templates/pages/settings-sidebar/page.tsx +39 -47
  172. package/templates/pages/side-gallery/page.tsx +6 -9
  173. package/templates/pages/table-grouped/page.tsx +11 -15
  174. package/templates/pages/theme-showcase/page.tsx +33 -37
  175. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  176. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -67
@@ -6,7 +6,6 @@ import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
7
  import {Command} from 'commander';
8
8
  import {registerUpgrade} from './upgrade.mjs';
9
- import {latestVersion} from '../codemods/registry.mjs';
10
9
 
11
10
  let tmpDir;
12
11
  let originalCwd;
@@ -15,7 +14,7 @@ let stdoutCalls;
15
14
  let exitCode;
16
15
 
17
16
  beforeEach(() => {
18
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xds-upgrade-test-'));
17
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-upgrade-test-'));
19
18
  originalCwd = process.cwd();
20
19
  process.chdir(tmpDir);
21
20
  logCalls = [];
@@ -53,13 +52,28 @@ function createProgram() {
53
52
  return program;
54
53
  }
55
54
 
56
- function writePkg(deps) {
55
+ function writePkg(deps = {}) {
57
56
  fs.writeFileSync(
58
57
  path.join(tmpDir, 'package.json'),
59
58
  JSON.stringify({name: 'fixture', dependencies: deps}, null, 2),
60
59
  );
61
60
  }
62
61
 
62
+ function writeInstalledCore(version, packageName = '@astryxdesign/core') {
63
+ const parts = packageName.split('/');
64
+ const dir = path.join(tmpDir, 'node_modules', ...parts);
65
+ fs.mkdirSync(dir, {recursive: true});
66
+ fs.writeFileSync(
67
+ path.join(dir, 'package.json'),
68
+ JSON.stringify({name: packageName, version}, null, 2),
69
+ );
70
+ }
71
+
72
+ function writeSourceFile() {
73
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
74
+ fs.writeFileSync(path.join(tmpDir, 'src', 'index.ts'), 'const x = 1;\n');
75
+ }
76
+
63
77
  /** Run a command and capture the parsed JSON response (last printed JSON line). */
64
78
  async function runJson(args) {
65
79
  const program = createProgram();
@@ -83,53 +97,53 @@ async function runJson(args) {
83
97
  }
84
98
 
85
99
  describe('upgrade gate (semver comparison)', () => {
86
- it('does NOT block an upgrade from 0.0.9 to 0.0.10 (regression)', async () => {
100
+ it('does NOT block an upgrade from 0.0.9 to installed 0.0.10 (regression)', async () => {
87
101
  // The original bug: string compare said '0.0.9' >= '0.0.10', so the
88
102
  // gate told users "Already up to date" without --force.
89
- writePkg({'@astryxdesign/core': '^0.0.9'});
103
+ writePkg();
104
+ writeInstalledCore('0.0.10');
105
+ writeSourceFile();
90
106
 
91
- const result = await runJson(['--json', 'upgrade', '--to', '0.0.10', '--codemod-only']);
92
- // Either a real run or "no codemods available" — but never the
93
- // up-to-date short-circuit (which has no `type` field).
107
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.9', '--path', 'src']);
94
108
  expect(result).not.toBeNull();
95
- // The receipt or "no codemods" path should not look like the
96
- // up-to-date short-circuit (which would return without printing JSON).
97
109
  expect(result.type === 'upgrade.run' || result.error || logCalls.some(l => l.includes('No codemods'))).toBeTruthy();
98
110
  });
99
111
 
100
- it('blocks when current >= target by semver (e.g. 0.0.10 → 0.0.9)', async () => {
101
- writePkg({'@astryxdesign/core': '^0.0.10'});
112
+ it('blocks when --from >= installed target by semver (e.g. 0.0.10 → 0.0.9)', async () => {
113
+ writePkg();
114
+ writeInstalledCore('0.0.9');
102
115
  const program = createProgram();
103
- await program.parseAsync(['node', 'astryx', 'upgrade', '--to', '0.0.9']);
116
+ await program.parseAsync(['node', 'astryx', 'upgrade', '--from', '0.0.10']);
104
117
  const output = stdoutCalls.join('') + logCalls.join('\n');
105
118
  expect(output).toMatch(/up to date|Already/i);
106
119
  });
107
120
  });
108
121
 
109
- describe('upgrade --to validation', () => {
110
- it('rejects bogus --to values with a structured error', async () => {
111
- writePkg({'@astryxdesign/core': '^0.0.5'});
112
- const result = await runJson(['--json', 'upgrade', '--to', 'bogus']);
122
+ describe('upgrade argument validation', () => {
123
+ it('requires --from for upgrade runs', async () => {
124
+ writePkg();
125
+ writeInstalledCore('0.0.15');
126
+ const result = await runJson(['--json', 'upgrade']);
113
127
  expect(result).not.toBeNull();
114
- expect(result.error).toMatch(/Invalid --to/);
128
+ expect(result.error).toMatch(/Missing required --from/);
115
129
  expect(exitCode).toBe(1);
116
130
  });
117
131
 
118
132
  it('rejects bogus --from values', async () => {
119
- writePkg({'@astryxdesign/core': '^0.0.5'});
120
- const result = await runJson(['--json', 'upgrade', '--from', 'not-a-version', '--to', '0.0.5']);
133
+ writePkg();
134
+ writeInstalledCore('0.0.15');
135
+ const result = await runJson(['--json', 'upgrade', '--from', 'not-a-version']);
121
136
  expect(result).not.toBeNull();
122
137
  expect(result.error).toMatch(/Invalid --from/);
123
138
  expect(exitCode).toBe(1);
124
139
  });
125
140
 
126
- it('accepts a valid semver --to', async () => {
127
- writePkg({'@astryxdesign/core': '^0.0.5'});
128
- // Don't actually run codemods — codemod-only + a target with no
129
- // matching transforms is enough to confirm validation passed.
130
- const result = await runJson(['--json', 'upgrade', '--to', latestVersion, '--codemod-only']);
131
- // No "Invalid --to" error means validation passed.
132
- expect(result?.error || '').not.toMatch(/Invalid --to/);
141
+ it('detects the installed target version from @astryxdesign/core', async () => {
142
+ writePkg();
143
+ writeInstalledCore('0.0.15');
144
+ writeSourceFile();
145
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.14', '--path', 'src']);
146
+ expect(result?.error || '').not.toMatch(/Could not find installed/);
133
147
  });
134
148
  });
135
149
 
package/src/config.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {validateConfig, validateIntegration} from './lib/config-schema.mjs';
4
+
5
+ /**
6
+ * Type-preserving helpers for Astryx config and integration manifests.
7
+ *
8
+ * These are intentionally tiny runtime identity functions. Their value is the
9
+ * exported TypeScript surface from `@astryxdesign/cli/config`, so config files
10
+ * can get editor/type feedback without coupling to CLI internals.
11
+ */
12
+
13
+ /**
14
+ * @template {import('./types/config').AstryxConfig} T
15
+ * @param {T} config
16
+ * @returns {T}
17
+ */
18
+ export function createConfig(config) {
19
+ validateConfig(config);
20
+ return config;
21
+ }
22
+
23
+ /**
24
+ * @template {import('./types/config').AstryxIntegration} T
25
+ * @param {T} integration
26
+ * @returns {T}
27
+ */
28
+ export function createIntegration(integration) {
29
+ validateIntegration(integration);
30
+ return integration;
31
+ }
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {createConfig, createIntegration} from './config.mjs';
5
+
6
+ describe('config helpers', () => {
7
+ it('return config and integration objects unchanged', () => {
8
+ const config = {integrations: ['@acme/widgets']};
9
+ const integration = {name: '@acme/widgets', docs: './docs'};
10
+ expect(createConfig(config)).toBe(config);
11
+ expect(createIntegration(integration)).toBe(integration);
12
+ });
13
+
14
+ it('validates config and integration shapes', () => {
15
+ expect(() => createConfig({integrations: [42]})).toThrow(/integrations/);
16
+ expect(() => createIntegration({docs: './docs'})).toThrow(/name/);
17
+ expect(() =>
18
+ createIntegration({
19
+ name: '@acme/widgets',
20
+ postCodemod: [{name: 'empty'}],
21
+ }),
22
+ ).toThrow(/postCodemod/);
23
+ });
24
+ });
package/src/index.mjs CHANGED
@@ -63,6 +63,9 @@ export const JSON_SUPPORTED = new Set([
63
63
  'upgrade',
64
64
  'manifest',
65
65
  'doctor',
66
+ 'layout expand',
67
+ 'layout check',
68
+ 'layout grammar',
66
69
  ]);
67
70
 
68
71
  program
@@ -243,12 +246,14 @@ const commands = [
243
246
  {name: 'swizzle', path: './commands/swizzle.mjs', register: 'registerSwizzle'},
244
247
  // agent-docs folded into init — functions still importable from agent-docs.mjs
245
248
  {name: 'template', path: './commands/template.mjs', register: 'registerTemplate'},
249
+ {name: 'layout', path: './commands/layout.mjs', register: 'registerLayout'},
246
250
  {name: 'gap-report', path: './commands/gap-report.mjs', register: 'registerGapReport'},
247
251
  {name: 'upgrade', path: './commands/upgrade.mjs', register: 'registerUpgrade'},
248
252
  {name: 'theme', path: './commands/build-theme.mjs', register: 'registerTheme'},
249
253
  {name: 'hook', path: './commands/hook/index.mjs', register: 'registerHook'},
250
254
  {name: 'discover', path: './commands/discover.mjs', register: 'registerDiscover'},
251
255
  {name: 'search', path: './commands/search.mjs', register: 'registerSearch'},
256
+ {name: 'build', path: './commands/build.mjs', register: 'registerBuild'},
252
257
  {name: 'doctor', path: './commands/doctor.mjs', register: 'registerDoctor'},
253
258
  ];
254
259
 
@@ -0,0 +1,119 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** Runtime schemas for Astryx config and integration manifests. */
4
+
5
+ import {z} from 'zod';
6
+
7
+ const Fn = z.custom(value => typeof value === 'function', {
8
+ message: 'Expected function',
9
+ });
10
+
11
+ const StringOrStringArray = z.union([z.string(), z.array(z.string())]);
12
+ const GapReportSchema = z.union([
13
+ z.literal(false),
14
+ z.object({command: z.string()}).passthrough(),
15
+ ]);
16
+
17
+ export const AstryxConfigSchema = z
18
+ .object({
19
+ packages: StringOrStringArray.optional(),
20
+ integrations: StringOrStringArray.optional(),
21
+ gapReport: GapReportSchema.optional(),
22
+ template: z
23
+ .object({
24
+ get: Fn.optional(),
25
+ })
26
+ .passthrough()
27
+ .optional(),
28
+ })
29
+ .passthrough();
30
+
31
+ export const AstryxIntegrationCodemodSchema = z
32
+ .object({
33
+ name: z.string(),
34
+ from: z.string().optional(),
35
+ to: z.string().optional(),
36
+ title: z.string().optional(),
37
+ description: z.string().optional(),
38
+ pr: z.string().optional(),
39
+ optional: z.boolean().optional(),
40
+ fileExtensions: z.array(z.string()).optional(),
41
+ transform: z.union([z.string(), Fn]),
42
+ })
43
+ .passthrough();
44
+
45
+ export const AstryxPostCodemodHookSchema = z
46
+ .object({
47
+ name: z.string().optional(),
48
+ run: Fn.optional(),
49
+ command: Fn.optional(),
50
+ })
51
+ .passthrough()
52
+ .refine(value => value.run || value.command, {
53
+ message: 'postCodemod hook must define run() or command()',
54
+ });
55
+
56
+ export const AstryxIntegrationSchema = z
57
+ .object({
58
+ name: z.string(),
59
+ version: z.string().optional(),
60
+ displayName: z.string().optional(),
61
+ description: z.string().optional(),
62
+ docs: z.string().optional(),
63
+ category: z.string().optional(),
64
+ blocks: z.string().optional(),
65
+ gapReport: GapReportSchema.optional(),
66
+ template: z
67
+ .object({
68
+ get: z.union([z.string(), Fn]).optional(),
69
+ })
70
+ .passthrough()
71
+ .optional(),
72
+ codemods: z.array(AstryxIntegrationCodemodSchema).optional(),
73
+ postCodemod: z.array(AstryxPostCodemodHookSchema).optional(),
74
+ })
75
+ .passthrough();
76
+
77
+ /**
78
+ * @param {string} label
79
+ * @param {import('zod').ZodError} error
80
+ */
81
+ function formatZodError(label, error) {
82
+ const issues = error.issues
83
+ .map(issue => {
84
+ const path = issue.path.length ? issue.path.join('.') : '(root)';
85
+ return `${path}: ${issue.message}`;
86
+ })
87
+ .join('; ');
88
+ return `${label} is invalid: ${issues}`;
89
+ }
90
+
91
+ /**
92
+ * @param {unknown} config
93
+ * @returns {import('../types/config').AstryxConfig}
94
+ */
95
+ export function validateConfig(config) {
96
+ const result = AstryxConfigSchema.safeParse(config);
97
+ if (!result.success) {
98
+ throw new Error(
99
+ formatZodError('astryx.config.mjs default export', result.error),
100
+ );
101
+ }
102
+ return result.data;
103
+ }
104
+
105
+ /**
106
+ * @param {unknown} integration
107
+ * @param {string} [label]
108
+ * @returns {import('../types/config').AstryxIntegration}
109
+ */
110
+ export function validateIntegration(
111
+ integration,
112
+ label = 'integration manifest',
113
+ ) {
114
+ const result = AstryxIntegrationSchema.safeParse(integration);
115
+ if (!result.success) {
116
+ throw new Error(formatZodError(label, result.error));
117
+ }
118
+ return result.data;
119
+ }
@@ -10,9 +10,12 @@
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import {pathToFileURL} from 'node:url';
13
+ import {loadIntegrations} from './integrations.mjs';
14
+ import {validateConfig} from './config-schema.mjs';
13
15
 
14
16
  const DEFAULTS = {
15
17
  packages: [],
18
+ integrations: [],
16
19
  };
17
20
 
18
21
  /**
@@ -39,17 +42,30 @@ export async function loadConfig(startDir = process.cwd()) {
39
42
  const configPath = findConfigPath(startDir);
40
43
  if (!configPath) return {...DEFAULTS};
41
44
 
45
+ let rawConfig;
42
46
  try {
43
47
  const mod = await import(pathToFileURL(configPath).href);
44
- const config = mod.default || {};
45
- return {
46
- ...DEFAULTS,
47
- ...config,
48
- packages: normalizePackages(config.packages, path.dirname(configPath)),
49
- };
48
+ rawConfig = mod.default || {};
50
49
  } catch {
51
50
  return {...DEFAULTS};
52
51
  }
52
+
53
+ const config = validateConfig(rawConfig);
54
+ const configDir = path.dirname(configPath);
55
+ const integrationSpecs = normalizeIntegrations(config.integrations);
56
+ const loadedIntegrations = await loadIntegrations(integrationSpecs, {
57
+ cwd: configDir,
58
+ });
59
+ return {
60
+ ...DEFAULTS,
61
+ ...config,
62
+ packages: normalizePackages(config.packages, configDir),
63
+ integrations: integrationSpecs,
64
+ loadedIntegrations,
65
+ gapReport:
66
+ config.gapReport ?? firstDefined(loadedIntegrations, 'gapReport'),
67
+ template: mergeTemplateConfig(config.template, loadedIntegrations),
68
+ };
53
69
  }
54
70
 
55
71
  /**
@@ -72,3 +88,26 @@ function normalizePackages(packages, configDir) {
72
88
  }
73
89
  return result;
74
90
  }
91
+
92
+ /**
93
+ * Normalize integration specs to a string array. Integrations are package names
94
+ * or manifest paths consumed by `astryx upgrade --integration`.
95
+ */
96
+ function normalizeIntegrations(integrations) {
97
+ if (!integrations) return [];
98
+ const arr = Array.isArray(integrations) ? integrations : [integrations];
99
+ return arr.filter(value => typeof value === 'string' && value !== '');
100
+ }
101
+
102
+ function firstDefined(integrations, key) {
103
+ for (const integration of integrations) {
104
+ if (integration[key] !== undefined) return integration[key];
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ function mergeTemplateConfig(template, integrations) {
110
+ if (template?.get) return template;
111
+ const integrationTemplate = firstDefined(integrations, 'template');
112
+ return integrationTemplate ?? template;
113
+ }
@@ -0,0 +1,91 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import {loadConfig} from './config.mjs';
7
+
8
+ let tmpDir;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-config-test-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, {recursive: true, force: true});
16
+ });
17
+
18
+ function installIntegrationPackage(dir, name = '@nest/xds-meta') {
19
+ const packageDir = path.join(dir, 'node_modules', ...name.split('/'));
20
+ fs.mkdirSync(packageDir, {recursive: true});
21
+ fs.writeFileSync(
22
+ path.join(packageDir, 'package.json'),
23
+ JSON.stringify({name, astryx: {integration: './astryx.integration.mjs'}}),
24
+ );
25
+ fs.writeFileSync(
26
+ path.join(packageDir, 'astryx.integration.mjs'),
27
+ `export default { name: '${name}' };
28
+ `,
29
+ );
30
+ }
31
+
32
+ describe('loadConfig', () => {
33
+ it('normalizes integrations from astryx.config.mjs', async () => {
34
+ const dir = path.join(tmpDir, 'one');
35
+ fs.mkdirSync(dir);
36
+ installIntegrationPackage(dir);
37
+ fs.writeFileSync(
38
+ path.join(dir, 'astryx.config.mjs'),
39
+ `export default { integrations: '@nest/xds-meta' };\n`,
40
+ );
41
+ await expect(loadConfig(dir)).resolves.toMatchObject({
42
+ integrations: ['@nest/xds-meta'],
43
+ });
44
+ });
45
+
46
+ it('rejects invalid integration package manifests', async () => {
47
+ const dir = path.join(tmpDir, 'bad');
48
+ fs.mkdirSync(dir);
49
+ const packageDir = path.join(dir, 'node_modules', '@bad', 'widgets');
50
+ fs.mkdirSync(packageDir, {recursive: true});
51
+ fs.writeFileSync(
52
+ path.join(dir, 'astryx.config.mjs'),
53
+ `export default { integrations: '@bad/widgets' };\n`,
54
+ );
55
+ fs.writeFileSync(
56
+ path.join(packageDir, 'package.json'),
57
+ JSON.stringify({
58
+ name: '@bad/widgets',
59
+ astryx: {integration: './astryx.integration.mjs'},
60
+ }),
61
+ );
62
+ fs.writeFileSync(
63
+ path.join(packageDir, 'astryx.integration.mjs'),
64
+ `export default { docs: './docs' };\n`,
65
+ );
66
+ await expect(loadConfig(dir)).rejects.toThrow(/name/);
67
+ });
68
+
69
+ it('normalizes integration arrays', async () => {
70
+ const dir = path.join(tmpDir, 'two');
71
+ fs.mkdirSync(dir);
72
+ installIntegrationPackage(dir);
73
+ fs.writeFileSync(
74
+ path.join(dir, 'astryx.config.mjs'),
75
+ `export default { integrations: ['@nest/xds-meta', ''] };\n`,
76
+ );
77
+ await expect(loadConfig(dir)).resolves.toMatchObject({
78
+ integrations: ['@nest/xds-meta'],
79
+ });
80
+ });
81
+
82
+ it('rejects invalid config shapes', async () => {
83
+ const dir = path.join(tmpDir, 'invalid');
84
+ fs.mkdirSync(dir);
85
+ fs.writeFileSync(
86
+ path.join(dir, 'astryx.config.mjs'),
87
+ `export default { integrations: [42] };\n`,
88
+ );
89
+ await expect(loadConfig(dir)).rejects.toThrow(/integrations/);
90
+ });
91
+ });
@@ -56,6 +56,7 @@
56
56
  * | 'ERR_UNKNOWN_AGENT'
57
57
  * | 'ERR_UNKNOWN_FEATURE'
58
58
  * | 'ERR_UNKNOWN_CODEMOD'
59
+ | 'ERR_CODEMOD_FAILED'
59
60
  * | 'ERR_NOT_FOUND'
60
61
  * | 'ERR_NO_DOC'
61
62
  * | 'ERR_NO_SHOWCASE'
@@ -74,6 +75,8 @@
74
75
  * | 'ERR_DEP_MISSING'
75
76
  * | 'ERR_GH_CLI'
76
77
  * | 'ERR_GAP_REPORT_FAILED'
78
+ * | 'ERR_LAYOUT_PARSE'
79
+ * | 'ERR_LAYOUT_INVALID'
77
80
  * )} ErrorCode
78
81
  */
79
82
 
@@ -129,6 +132,8 @@ export const ERROR_CODES = Object.freeze({
129
132
  ERR_UNKNOWN_FEATURE: 'ERR_UNKNOWN_FEATURE',
130
133
  /** A `--codemod` value did not match any registered codemod (upgrade). */
131
134
  ERR_UNKNOWN_CODEMOD: 'ERR_UNKNOWN_CODEMOD',
135
+ /** One or more codemods failed during an upgrade run. */
136
+ ERR_CODEMOD_FAILED: 'ERR_CODEMOD_FAILED',
132
137
  /** A generic discover/lookup query matched nothing in any package. */
133
138
  ERR_NOT_FOUND: 'ERR_NOT_FOUND',
134
139
 
@@ -177,6 +182,12 @@ export const ERROR_CODES = Object.freeze({
177
182
  ERR_GH_CLI: 'ERR_GH_CLI',
178
183
  /** Filing a gap report failed at the command/integration boundary. */
179
184
  ERR_GAP_REPORT_FAILED: 'ERR_GAP_REPORT_FAILED',
185
+
186
+ // ── Layout expressions (XLE/XLO) ─────────────────────────────────
187
+ /** A layout expression failed to parse (syntax error, with line/col). */
188
+ ERR_LAYOUT_PARSE: 'ERR_LAYOUT_PARSE',
189
+ /** A layout expression parsed but failed validation (unknown component/prop/enum/block). */
190
+ ERR_LAYOUT_INVALID: 'ERR_LAYOUT_INVALID',
180
191
  });
181
192
 
182
193
  /**
@@ -0,0 +1,155 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration manifest loading for Astryx config.
5
+ *
6
+ * Integrations are package names or direct manifest paths listed in
7
+ * astryx.config.mjs. A manifest can contribute docs/discovery metadata,
8
+ * gap-report/template hooks, upgrade codemods, and post-codemod hooks.
9
+ */
10
+
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import {pathToFileURL} from 'node:url';
14
+ import {validateIntegration} from './config-schema.mjs';
15
+
16
+ export function isPathSpec(spec) {
17
+ return (
18
+ spec.startsWith('.') ||
19
+ spec.startsWith('/') ||
20
+ spec.endsWith('.mjs') ||
21
+ spec.endsWith('.js')
22
+ );
23
+ }
24
+
25
+ export function resolvePackageDir(packageName, cwd = process.cwd()) {
26
+ return path.resolve(cwd, 'node_modules', ...packageName.split('/'));
27
+ }
28
+
29
+ export function resolveIntegrationFile(spec, cwd = process.cwd()) {
30
+ if (isPathSpec(spec)) {
31
+ return path.resolve(cwd, spec);
32
+ }
33
+
34
+ const packageDir = resolvePackageDir(spec, cwd);
35
+ const pkgPath = path.join(packageDir, 'package.json');
36
+ let pkg;
37
+ try {
38
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
39
+ } catch {
40
+ throw new Error(
41
+ `Could not find installed integration package "${spec}" at ${pkgPath}. Install it first or pass a direct integration file path.`,
42
+ );
43
+ }
44
+
45
+ const manifestPath = pkg.astryx?.integration ?? pkg.xds?.integration;
46
+ if (!manifestPath) {
47
+ throw new Error(
48
+ `Package "${spec}" does not declare astryx.integration (or legacy xds.integration) in package.json.`,
49
+ );
50
+ }
51
+ return path.resolve(packageDir, manifestPath);
52
+ }
53
+
54
+ function normalizePackageContribution(integration) {
55
+ const docs = integration.docs;
56
+ if (!docs) return null;
57
+ const packageDir = integration.__packageDir ?? integration.__dir;
58
+ const docsDir = path.resolve(packageDir, docs);
59
+ return {
60
+ name: integration.name ?? integration.__spec,
61
+ version: integration.version,
62
+ description: integration.description,
63
+ displayName: integration.displayName,
64
+ dir: packageDir,
65
+ astryx: {
66
+ docs,
67
+ category: integration.category,
68
+ blocks: integration.blocks,
69
+ },
70
+ category: integration.category ?? integration.name ?? integration.__spec,
71
+ docsDir,
72
+ };
73
+ }
74
+
75
+ function resolveHookFunction(hook, integration) {
76
+ if (typeof hook === 'function') return hook;
77
+ if (typeof hook !== 'string') return hook;
78
+ const [moduleSpec, exportName = 'default'] = hook.split('#');
79
+ return async (...args) => {
80
+ const mod = isPathSpec(moduleSpec)
81
+ ? await import(
82
+ pathToFileURL(path.resolve(integration.__dir, moduleSpec)).href
83
+ )
84
+ : await import(moduleSpec);
85
+ const fn = exportName === 'default' ? mod.default : mod[exportName];
86
+ if (typeof fn !== 'function') {
87
+ throw new Error(
88
+ `Integration hook ${hook} did not resolve to a function.`,
89
+ );
90
+ }
91
+ return fn(...args);
92
+ };
93
+ }
94
+
95
+ export async function loadIntegrations(specs = [], {cwd = process.cwd()} = {}) {
96
+ const integrations = [];
97
+ const seen = new Set();
98
+
99
+ for (const spec of specs) {
100
+ if (!spec || seen.has(spec)) continue;
101
+ seen.add(spec);
102
+
103
+ const file = resolveIntegrationFile(spec, cwd);
104
+ const mod = await import(pathToFileURL(file).href);
105
+ const exported = mod.default ?? mod.integration ?? mod;
106
+ if (!exported || typeof exported !== 'object') {
107
+ throw new Error(`Integration ${spec} did not export an object.`);
108
+ }
109
+ const integration = validateIntegration(exported, `Integration ${spec}`);
110
+
111
+ const integrationDir = path.dirname(file);
112
+ const packageDir = isPathSpec(spec)
113
+ ? integrationDir
114
+ : resolvePackageDir(spec, cwd);
115
+ const normalized = {
116
+ ...integration,
117
+ __file: file,
118
+ __dir: integrationDir,
119
+ __packageDir: packageDir,
120
+ __spec: spec,
121
+ };
122
+
123
+ if (Array.isArray(normalized.codemods)) {
124
+ for (const codemod of normalized.codemods) {
125
+ if (typeof codemod.transform === 'string') {
126
+ const transformPath = path.resolve(integrationDir, codemod.transform);
127
+ const transformMod = await import(pathToFileURL(transformPath).href);
128
+ codemod.transform =
129
+ transformMod.default ?? transformMod.transform ?? transformMod;
130
+ }
131
+ }
132
+ }
133
+
134
+ normalized.package = normalizePackageContribution(normalized);
135
+ if (
136
+ normalized.gapReport?.command &&
137
+ isPathSpec(normalized.gapReport.command)
138
+ ) {
139
+ normalized.gapReport = {
140
+ ...normalized.gapReport,
141
+ command: path.resolve(packageDir, normalized.gapReport.command),
142
+ };
143
+ }
144
+ if (normalized.template?.get) {
145
+ normalized.template = {
146
+ ...normalized.template,
147
+ get: resolveHookFunction(normalized.template.get, normalized),
148
+ };
149
+ }
150
+
151
+ integrations.push(normalized);
152
+ }
153
+
154
+ return integrations;
155
+ }