@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3

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 (113) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +19 -8
  5. package/src/api/discover.mjs +78 -26
  6. package/src/api/index.mjs +1 -0
  7. package/src/api/layout.mjs +301 -0
  8. package/src/api/layout.test.mjs +238 -0
  9. package/src/api/template.mjs +191 -50
  10. package/src/api/template.test.mjs +2 -0
  11. package/src/api/theme-add.mjs +182 -0
  12. package/src/codemods/__tests__/registry.test.mjs +1 -0
  13. package/src/codemods/registry.mjs +1 -0
  14. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  15. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  17. package/src/commands/build-theme.import-path.test.mjs +22 -2
  18. package/src/commands/build-theme.mjs +185 -16
  19. package/src/commands/gap-report.mjs +17 -9
  20. package/src/commands/gap-report.test.mjs +21 -16
  21. package/src/commands/init.mjs +34 -8
  22. package/src/commands/init.next-steps.test.mjs +46 -0
  23. package/src/commands/layout.mjs +139 -0
  24. package/src/commands/swizzle.mjs +51 -23
  25. package/src/commands/upgrade.mjs +2 -71
  26. package/src/config.mjs +31 -0
  27. package/src/config.test.mjs +24 -0
  28. package/src/index.mjs +6 -0
  29. package/src/lib/config-schema.mjs +119 -0
  30. package/src/lib/config.mjs +34 -7
  31. package/src/lib/config.test.mjs +51 -2
  32. package/src/lib/error-codes.mjs +11 -0
  33. package/src/lib/integrations.mjs +155 -0
  34. package/src/lib/integrations.test.mjs +154 -0
  35. package/src/lib/levenshtein.mjs +29 -0
  36. package/src/lib/manifest.mjs +10 -0
  37. package/src/lib/package-scanner.mjs +31 -7
  38. package/src/lib/string-utils.mjs +5 -14
  39. package/src/lib/xle/browser.d.ts +100 -0
  40. package/src/lib/xle/browser.mjs +120 -0
  41. package/src/lib/xle/expand.mjs +622 -0
  42. package/src/lib/xle/parse.mjs +581 -0
  43. package/src/lib/xle/print.mjs +174 -0
  44. package/src/lib/xle/registry-core.mjs +170 -0
  45. package/src/lib/xle/registry.mjs +237 -0
  46. package/src/lib/xle/splice.mjs +137 -0
  47. package/src/lib/xle/validate.mjs +356 -0
  48. package/src/lib/xle/xle.test.mjs +333 -0
  49. package/src/types/config.d.ts +99 -0
  50. package/src/utils/github.mjs +12 -27
  51. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  52. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  53. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  54. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  55. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  56. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  57. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  58. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  59. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  60. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  61. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  62. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  63. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  64. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  65. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  66. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  67. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  68. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  69. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  70. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  71. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  72. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  73. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  74. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  75. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  76. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  77. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  78. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  79. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  80. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  81. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  82. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  83. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  84. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  85. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  86. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  87. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  88. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  89. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  90. package/templates/pages/shell-nav/page.tsx +321 -0
  91. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  92. package/templates/pages/shell-side-nav/page.tsx +242 -0
  93. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  94. package/templates/pages/shell-top-nav/page.tsx +224 -0
  95. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  96. package/templates/pages/theme-showcase/page.tsx +8 -19
  97. package/templates/themes/butter/butterTheme.ts +916 -0
  98. package/templates/themes/butter/icons.tsx +77 -0
  99. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  100. package/templates/themes/chocolate/icons.tsx +77 -0
  101. package/templates/themes/gothic/gothicTheme.ts +657 -0
  102. package/templates/themes/gothic/icons.tsx +77 -0
  103. package/templates/themes/manifest.json +90 -0
  104. package/templates/themes/matcha/icons.tsx +67 -0
  105. package/templates/themes/matcha/matchaTheme.ts +247 -0
  106. package/templates/themes/neutral/icons.tsx +77 -0
  107. package/templates/themes/neutral/neutralTheme.ts +603 -0
  108. package/templates/themes/stone/icons.tsx +77 -0
  109. package/templates/themes/stone/stoneTheme.ts +652 -0
  110. package/templates/themes/y2k/icons.tsx +67 -0
  111. package/templates/themes/y2k/y2kTheme.ts +617 -0
  112. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  113. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
@@ -15,7 +15,11 @@ import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import * as p from '@clack/prompts';
17
17
  import {findCoreDir, listComponents} from '../utils/paths.mjs';
18
- import {assertWithin, PathSafetyError, isNonInteractive} from '../utils/path-safety.mjs';
18
+ import {
19
+ assertWithin,
20
+ PathSafetyError,
21
+ isNonInteractive,
22
+ } from '../utils/path-safety.mjs';
19
23
  import {isInteractive} from '../utils/interactive.mjs';
20
24
  import {jsonOut, humanLog} from '../lib/json.mjs';
21
25
  import {cliError} from '../lib/cli-error.mjs';
@@ -104,7 +108,9 @@ export function registerSwizzle(program) {
104
108
  }
105
109
  humanLog(`\nUsage: astryx swizzle <component>\n`);
106
110
  humanLog('Example: astryx swizzle Button');
107
- humanLog(' astryx swizzle XDSButton (XDS prefix also works)\n');
111
+ humanLog(
112
+ ' astryx swizzle XDSButton (XDS prefix also works)\n',
113
+ );
108
114
  return;
109
115
  }
110
116
 
@@ -112,10 +118,10 @@ export function registerSwizzle(program) {
112
118
  const componentDir = path.join(coreDir, 'src', dirName);
113
119
 
114
120
  if (!fs.existsSync(componentDir)) {
115
- cliError(
116
- `Component "${component}" not found.`,
117
- {suggestions: components.slice(0, 10).map((n) => ({name: n})), code: ERROR_CODES.ERR_UNKNOWN_COMPONENT},
118
- );
121
+ cliError(`Component "${component}" not found.`, {
122
+ suggestions: components.slice(0, 10).map(n => ({name: n})),
123
+ code: ERROR_CODES.ERR_UNKNOWN_COMPONENT,
124
+ });
119
125
  return;
120
126
  }
121
127
 
@@ -196,11 +202,16 @@ export function registerSwizzle(program) {
196
202
  }
197
203
 
198
204
  const relOutput = path.relative(process.cwd(), outputDir);
199
- const copiedFiles = files.filter(f => !f.includes('.test.') && f !== 'README.md' && fs.statSync(path.join(componentDir, f)).isFile());
205
+ const copiedFiles = files.filter(
206
+ f =>
207
+ !f.includes('.test.') &&
208
+ f !== 'README.md' &&
209
+ fs.statSync(path.join(componentDir, f)).isFile(),
210
+ );
200
211
 
201
212
  // --- Gap reporting ---
202
213
 
203
- const gapConfig = loadGapReportConfig();
214
+ const gapConfig = await loadGapReportConfig();
204
215
  let gapReportUrl = null;
205
216
  let gapDryRunPreview = null;
206
217
 
@@ -224,7 +235,7 @@ export function registerSwizzle(program) {
224
235
  json,
225
236
  });
226
237
 
227
- const preview = buildGapReportPreview(previewArgs);
238
+ const preview = await buildGapReportPreview(previewArgs);
228
239
 
229
240
  if (!willFile) {
230
241
  // Dry-run: do NOT call gh. Surface what would have been filed.
@@ -239,10 +250,12 @@ export function registerSwizzle(program) {
239
250
  };
240
251
  } else if (gapConfig.command || checkGhCli()) {
241
252
  try {
242
- gapReportUrl = createGapReport(previewArgs);
253
+ gapReportUrl = await createGapReport(previewArgs);
243
254
  } catch (err) {
244
255
  if (!json)
245
- console.error(`Warning: Could not file gap report: ${err.message}`);
256
+ console.error(
257
+ `Warning: Could not file gap report: ${err.message}`,
258
+ );
246
259
  }
247
260
  }
248
261
  }
@@ -259,17 +272,23 @@ export function registerSwizzle(program) {
259
272
  gapReportSuppressed: reportingSuppressed || !gapConfig.enabled,
260
273
  });
261
274
  humanLog(`\n✓ Copied ${copied} files to ${relOutput}/\n`);
262
- humanLog('Relative imports have been rewritten to use @astryxdesign/core.');
275
+ humanLog(
276
+ 'Relative imports have been rewritten to use @astryxdesign/core.',
277
+ );
263
278
  humanLog('You can now customize the component source freely.\n');
264
279
  if (gapReportUrl) {
265
280
  humanLog(`✓ Gap report filed: ${gapReportUrl}\n`);
266
281
  } else if (gapDryRunPreview) {
267
- humanLog(formatPreview(buildGapReportPreview({
268
- component: dirName,
269
- category: options.gapCategory || 'other',
270
- intention: options.gap,
271
- source: 'llm-auto',
272
- })));
282
+ humanLog(
283
+ formatPreview(
284
+ await buildGapReportPreview({
285
+ component: dirName,
286
+ category: options.gapCategory || 'other',
287
+ intention: options.gap,
288
+ source: 'llm-auto',
289
+ }),
290
+ ),
291
+ );
273
292
  humanLog(
274
293
  '\n[dry-run] No gap report was filed. Re-run with --commit to file.',
275
294
  );
@@ -283,10 +302,18 @@ export function registerSwizzle(program) {
283
302
  return;
284
303
  }
285
304
 
286
- if (json) return jsonOut('swizzle.copy', {component: dirName, outputDir: relOutput, filesCopied: copied, files: copiedFiles.map(f => f)});
305
+ if (json)
306
+ return jsonOut('swizzle.copy', {
307
+ component: dirName,
308
+ outputDir: relOutput,
309
+ filesCopied: copied,
310
+ files: copiedFiles.map(f => f),
311
+ });
287
312
 
288
313
  humanLog(`\n✓ Copied ${copied} files to ${relOutput}/\n`);
289
- humanLog('Relative imports have been rewritten to use @astryxdesign/core.');
314
+ humanLog(
315
+ 'Relative imports have been rewritten to use @astryxdesign/core.',
316
+ );
290
317
  humanLog('You can now customize the component source freely.\n');
291
318
 
292
319
  if (reportingSuppressed || !gapConfig.enabled) {
@@ -330,7 +357,8 @@ export function registerSwizzle(program) {
330
357
  placeholder:
331
358
  'e.g. "Need a compact variant for use in dense data tables"',
332
359
  validate: val => {
333
- if (!val.trim()) return 'Please describe what you were trying to do';
360
+ if (!val.trim())
361
+ return 'Please describe what you were trying to do';
334
362
  },
335
363
  }),
336
364
  );
@@ -350,7 +378,7 @@ export function registerSwizzle(program) {
350
378
  source: 'interactive',
351
379
  };
352
380
 
353
- const preview = buildGapReportPreview(previewArgs);
381
+ const preview = await buildGapReportPreview(previewArgs);
354
382
 
355
383
  p.note(
356
384
  `${preview.mode === 'github' ? `Repo: ${preview.repo}` : `Custom command: ${preview.command}`}\n\n` +
@@ -378,7 +406,7 @@ export function registerSwizzle(program) {
378
406
  s.start('Filing gap report');
379
407
 
380
408
  try {
381
- const url = createGapReport(previewArgs);
409
+ const url = await createGapReport(previewArgs);
382
410
  s.stop('Gap report filed');
383
411
  humanLog(`✓ ${url}\n`);
384
412
  } catch (err) {
@@ -26,7 +26,6 @@
26
26
 
27
27
  import * as fs from 'node:fs';
28
28
  import * as path from 'node:path';
29
- import {pathToFileURL} from 'node:url';
30
29
  import {execFile} from 'node:child_process';
31
30
  import {promisify} from 'node:util';
32
31
  import * as p from '@clack/prompts';
@@ -38,6 +37,7 @@ import {getRunPrefix} from '../utils/package-manager.mjs';
38
37
  import {isValidSemver, semverGte, semverGt} from '../utils/semver.mjs';
39
38
  import {jsonOut, jsonError} from '../lib/json.mjs';
40
39
  import {loadConfig} from '../lib/config.mjs';
40
+ import {loadIntegrations} from '../lib/integrations.mjs';
41
41
  import {ERROR_CODES} from '../lib/error-codes.mjs';
42
42
 
43
43
  const execFileAsync = promisify(execFile);
@@ -64,75 +64,6 @@ function detectInstalledTargetVersion() {
64
64
  return null;
65
65
  }
66
66
 
67
- function isPathSpec(spec) {
68
- return (
69
- spec.startsWith('.') ||
70
- spec.startsWith('/') ||
71
- spec.endsWith('.mjs') ||
72
- spec.endsWith('.js')
73
- );
74
- }
75
-
76
- function resolvePackageDir(packageName) {
77
- const parts = packageName.split('/');
78
- return path.resolve(process.cwd(), 'node_modules', ...parts);
79
- }
80
-
81
- function resolveIntegrationFile(spec) {
82
- if (isPathSpec(spec)) {
83
- return path.resolve(process.cwd(), spec);
84
- }
85
-
86
- const packageDir = resolvePackageDir(spec);
87
- const pkgPath = path.join(packageDir, 'package.json');
88
- let pkg;
89
- try {
90
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
91
- } catch {
92
- throw new Error(
93
- `Could not find installed integration package "${spec}" at ${pkgPath}. Install it first or pass a direct integration file path.`,
94
- );
95
- }
96
-
97
- const manifestPath = pkg.astryx?.integration ?? pkg.xds?.integration;
98
- if (!manifestPath) {
99
- throw new Error(
100
- `Package "${spec}" does not declare astryx.integration (or legacy xds.integration) in package.json.`,
101
- );
102
- }
103
- return path.resolve(packageDir, manifestPath);
104
- }
105
-
106
- async function loadIntegrations(specs) {
107
- const integrations = [];
108
- for (const spec of specs) {
109
- const file = resolveIntegrationFile(spec);
110
- const mod = await import(pathToFileURL(file).href);
111
- const integration = mod.default ?? mod.integration ?? mod;
112
- if (!integration || typeof integration !== 'object') {
113
- throw new Error(`Integration ${spec} did not export an object.`);
114
- }
115
- const integrationDir = path.dirname(file);
116
- if (Array.isArray(integration.codemods)) {
117
- for (const codemod of integration.codemods) {
118
- if (typeof codemod.transform === 'string') {
119
- const transformPath = path.resolve(integrationDir, codemod.transform);
120
- const transformMod = await import(pathToFileURL(transformPath).href);
121
- codemod.transform =
122
- transformMod.default ?? transformMod.transform ?? transformMod;
123
- }
124
- }
125
- }
126
- integrations.push({
127
- ...integration,
128
- __file: file,
129
- __dir: integrationDir,
130
- __spec: spec,
131
- });
132
- }
133
- return integrations;
134
- }
135
-
136
67
  function normalizeIntegrationTransforms(integration, from, to) {
137
68
  const transforms = [];
138
69
  for (const entry of integration.codemods ?? []) {
@@ -507,7 +438,7 @@ export function registerUpgrade(program) {
507
438
  } catch {
508
439
  if (!json) {
509
440
  p.log.warn(
510
- `Could not update agent docs. Run \`${getRunPrefix()} astryx agent-docs\` to update manually.`,
441
+ `Could not update agent docs. Run \`${getRunPrefix()} astryx init --features agents\` to update manually.`,
511
442
  );
512
443
  }
513
444
  }
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
@@ -59,10 +59,15 @@ export const JSON_SUPPORTED = new Set([
59
59
  'template',
60
60
  'hook',
61
61
  'theme build',
62
+ 'theme list',
63
+ 'theme add',
62
64
  'gap-report',
63
65
  'upgrade',
64
66
  'manifest',
65
67
  'doctor',
68
+ 'layout expand',
69
+ 'layout check',
70
+ 'layout grammar',
66
71
  ]);
67
72
 
68
73
  program
@@ -243,6 +248,7 @@ const commands = [
243
248
  {name: 'swizzle', path: './commands/swizzle.mjs', register: 'registerSwizzle'},
244
249
  // agent-docs folded into init — functions still importable from agent-docs.mjs
245
250
  {name: 'template', path: './commands/template.mjs', register: 'registerTemplate'},
251
+ {name: 'layout', path: './commands/layout.mjs', register: 'registerLayout'},
246
252
  {name: 'gap-report', path: './commands/gap-report.mjs', register: 'registerGapReport'},
247
253
  {name: 'upgrade', path: './commands/upgrade.mjs', register: 'registerUpgrade'},
248
254
  {name: 'theme', path: './commands/build-theme.mjs', register: 'registerTheme'},
@@ -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,6 +10,8 @@
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: [],
@@ -40,18 +42,30 @@ export async function loadConfig(startDir = process.cwd()) {
40
42
  const configPath = findConfigPath(startDir);
41
43
  if (!configPath) return {...DEFAULTS};
42
44
 
45
+ let rawConfig;
43
46
  try {
44
47
  const mod = await import(pathToFileURL(configPath).href);
45
- const config = mod.default || {};
46
- return {
47
- ...DEFAULTS,
48
- ...config,
49
- packages: normalizePackages(config.packages, path.dirname(configPath)),
50
- integrations: normalizeIntegrations(config.integrations),
51
- };
48
+ rawConfig = mod.default || {};
52
49
  } catch {
53
50
  return {...DEFAULTS};
54
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
+ };
55
69
  }
56
70
 
57
71
  /**
@@ -84,3 +98,16 @@ function normalizeIntegrations(integrations) {
84
98
  const arr = Array.isArray(integrations) ? integrations : [integrations];
85
99
  return arr.filter(value => typeof value === 'string' && value !== '');
86
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
+ }
@@ -15,10 +15,25 @@ afterEach(() => {
15
15
  fs.rmSync(tmpDir, {recursive: true, force: true});
16
16
  });
17
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
+
18
32
  describe('loadConfig', () => {
19
33
  it('normalizes integrations from astryx.config.mjs', async () => {
20
34
  const dir = path.join(tmpDir, 'one');
21
35
  fs.mkdirSync(dir);
36
+ installIntegrationPackage(dir);
22
37
  fs.writeFileSync(
23
38
  path.join(dir, 'astryx.config.mjs'),
24
39
  `export default { integrations: '@nest/xds-meta' };\n`,
@@ -28,15 +43,49 @@ describe('loadConfig', () => {
28
43
  });
29
44
  });
30
45
 
31
- it('normalizes integration arrays and filters non-strings', async () => {
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 () => {
32
70
  const dir = path.join(tmpDir, 'two');
33
71
  fs.mkdirSync(dir);
72
+ installIntegrationPackage(dir);
34
73
  fs.writeFileSync(
35
74
  path.join(dir, 'astryx.config.mjs'),
36
- `export default { integrations: ['@nest/xds-meta', '', 42] };\n`,
75
+ `export default { integrations: ['@nest/xds-meta', ''] };\n`,
37
76
  );
38
77
  await expect(loadConfig(dir)).resolves.toMatchObject({
39
78
  integrations: ['@nest/xds-meta'],
40
79
  });
41
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
+ });
42
91
  });
@@ -52,6 +52,7 @@
52
52
  * | 'ERR_UNKNOWN_SECTION'
53
53
  * | 'ERR_UNKNOWN_CATEGORY'
54
54
  * | 'ERR_UNKNOWN_TEMPLATE'
55
+ * | 'ERR_UNKNOWN_THEME'
55
56
  * | 'ERR_UNKNOWN_PACKAGE'
56
57
  * | 'ERR_UNKNOWN_AGENT'
57
58
  * | 'ERR_UNKNOWN_FEATURE'
@@ -75,6 +76,8 @@
75
76
  * | 'ERR_DEP_MISSING'
76
77
  * | 'ERR_GH_CLI'
77
78
  * | 'ERR_GAP_REPORT_FAILED'
79
+ * | 'ERR_LAYOUT_PARSE'
80
+ * | 'ERR_LAYOUT_INVALID'
78
81
  * )} ErrorCode
79
82
  */
80
83
 
@@ -122,6 +125,8 @@ export const ERROR_CODES = Object.freeze({
122
125
  ERR_UNKNOWN_CATEGORY: 'ERR_UNKNOWN_CATEGORY',
123
126
  /** No template matched the requested name. */
124
127
  ERR_UNKNOWN_TEMPLATE: 'ERR_UNKNOWN_TEMPLATE',
128
+ /** No theme matched the requested slug (theme add). */
129
+ ERR_UNKNOWN_THEME: 'ERR_UNKNOWN_THEME',
125
130
  /** No package matched the requested name (discover). */
126
131
  ERR_UNKNOWN_PACKAGE: 'ERR_UNKNOWN_PACKAGE',
127
132
  /** An unrecognized `--agent` value was passed to agent-docs/init. */
@@ -180,6 +185,12 @@ export const ERROR_CODES = Object.freeze({
180
185
  ERR_GH_CLI: 'ERR_GH_CLI',
181
186
  /** Filing a gap report failed at the command/integration boundary. */
182
187
  ERR_GAP_REPORT_FAILED: 'ERR_GAP_REPORT_FAILED',
188
+
189
+ // ── Layout expressions (XLE/XLO) ─────────────────────────────────
190
+ /** A layout expression failed to parse (syntax error, with line/col). */
191
+ ERR_LAYOUT_PARSE: 'ERR_LAYOUT_PARSE',
192
+ /** A layout expression parsed but failed validation (unknown component/prop/enum/block). */
193
+ ERR_LAYOUT_INVALID: 'ERR_LAYOUT_INVALID',
183
194
  });
184
195
 
185
196
  /**