@astryxdesign/cli 0.1.1-canary.a514b99 → 0.1.1-canary.ac73e47

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 (49) hide show
  1. package/package.json +19 -8
  2. package/src/api/discover.mjs +78 -26
  3. package/src/api/layout.mjs +301 -0
  4. package/src/api/layout.test.mjs +238 -0
  5. package/src/api/template.mjs +191 -50
  6. package/src/api/template.test.mjs +2 -0
  7. package/src/commands/gap-report.mjs +17 -9
  8. package/src/commands/gap-report.test.mjs +21 -16
  9. package/src/commands/init.mjs +34 -8
  10. package/src/commands/init.next-steps.test.mjs +46 -0
  11. package/src/commands/layout.mjs +139 -0
  12. package/src/commands/swizzle.mjs +51 -23
  13. package/src/commands/upgrade.mjs +1 -70
  14. package/src/config.mjs +31 -0
  15. package/src/config.test.mjs +24 -0
  16. package/src/index.mjs +4 -0
  17. package/src/lib/config-schema.mjs +119 -0
  18. package/src/lib/config.mjs +34 -7
  19. package/src/lib/config.test.mjs +51 -2
  20. package/src/lib/error-codes.mjs +8 -0
  21. package/src/lib/integrations.mjs +155 -0
  22. package/src/lib/integrations.test.mjs +154 -0
  23. package/src/lib/levenshtein.mjs +29 -0
  24. package/src/lib/manifest.mjs +6 -0
  25. package/src/lib/package-scanner.mjs +31 -7
  26. package/src/lib/string-utils.mjs +5 -14
  27. package/src/lib/xle/browser.d.ts +91 -0
  28. package/src/lib/xle/browser.mjs +120 -0
  29. package/src/lib/xle/expand.mjs +622 -0
  30. package/src/lib/xle/parse.mjs +581 -0
  31. package/src/lib/xle/print.mjs +174 -0
  32. package/src/lib/xle/registry-core.mjs +170 -0
  33. package/src/lib/xle/registry.mjs +237 -0
  34. package/src/lib/xle/splice.mjs +137 -0
  35. package/src/lib/xle/validate.mjs +356 -0
  36. package/src/lib/xle/xle.test.mjs +333 -0
  37. package/src/types/config.d.ts +99 -0
  38. package/src/utils/github.mjs +12 -27
  39. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  40. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  41. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  42. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  43. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  44. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  45. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  46. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  47. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  48. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  49. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
@@ -132,7 +132,7 @@ export function registerGapReport(program) {
132
132
  .action(async () => {
133
133
  p.intro('Gap report setup');
134
134
 
135
- const config = loadGapReportConfig();
135
+ const config = await loadGapReportConfig();
136
136
  const currentMode = !config.enabled
137
137
  ? 'disabled'
138
138
  : config.command
@@ -252,9 +252,14 @@ export function registerGapReport(program) {
252
252
  return;
253
253
  }
254
254
 
255
- const config = loadGapReportConfig();
255
+ const config = await loadGapReportConfig();
256
256
  if (!config.enabled) {
257
- if (json) return jsonError('Gap reporting is disabled', undefined, ERROR_CODES.ERR_GAP_REPORT_FAILED);
257
+ if (json)
258
+ return jsonError(
259
+ 'Gap reporting is disabled',
260
+ undefined,
261
+ ERROR_CODES.ERR_GAP_REPORT_FAILED,
262
+ );
258
263
  humanLog(
259
264
  `Gap reporting is disabled (ASTRYX_GAP_REPORT=off or astryx.config.mjs).\n` +
260
265
  `Run \`${getRunPrefix()} astryx gap-report setup\` to configure.`,
@@ -295,7 +300,7 @@ export function registerGapReport(program) {
295
300
  return;
296
301
  }
297
302
 
298
- const preview = buildGapReportPreview({
303
+ const preview = await buildGapReportPreview({
299
304
  component: options.component,
300
305
  category: options.category,
301
306
  intention: options.reason,
@@ -326,7 +331,7 @@ export function registerGapReport(program) {
326
331
  }
327
332
 
328
333
  try {
329
- const url = createGapReport({
334
+ const url = await createGapReport({
330
335
  component: options.component,
331
336
  category: options.category,
332
337
  intention: options.reason,
@@ -343,7 +348,9 @@ export function registerGapReport(program) {
343
348
  humanLog('\nGap reporting is disabled via configuration.\n');
344
349
  }
345
350
  } catch (err) {
346
- cliError(`Filing gap report failed: ${err.message}`, {code: ERROR_CODES.ERR_GAP_REPORT_FAILED});
351
+ cliError(`Filing gap report failed: ${err.message}`, {
352
+ code: ERROR_CODES.ERR_GAP_REPORT_FAILED,
353
+ });
347
354
  return;
348
355
  }
349
356
  return;
@@ -386,7 +393,8 @@ export function registerGapReport(program) {
386
393
  placeholder:
387
394
  'e.g. "Need a compact variant for use in dense data tables"',
388
395
  validate: val => {
389
- if (!val.trim()) return 'Please describe what you were trying to do';
396
+ if (!val.trim())
397
+ return 'Please describe what you were trying to do';
390
398
  },
391
399
  }),
392
400
  );
@@ -406,7 +414,7 @@ export function registerGapReport(program) {
406
414
  source: 'interactive',
407
415
  };
408
416
 
409
- const preview = buildGapReportPreview(previewArgs);
417
+ const preview = await buildGapReportPreview(previewArgs);
410
418
 
411
419
  // Always show the user exactly what would be filed before sending.
412
420
  p.note(
@@ -440,7 +448,7 @@ export function registerGapReport(program) {
440
448
  s.start('Filing gap report');
441
449
 
442
450
  try {
443
- const url = createGapReport(previewArgs);
451
+ const url = await createGapReport(previewArgs);
444
452
  s.stop(url ? 'Gap report filed' : 'Gap reporting is disabled');
445
453
  if (url) {
446
454
  p.log.success(url);
@@ -40,9 +40,9 @@ describe('shouldActuallyFile', () => {
40
40
  });
41
41
 
42
42
  it('--dry-run wins over --commit (safety)', () => {
43
- expect(
44
- shouldActuallyFile({isTTY: true, commit: true, dryRun: true}),
45
- ).toBe(false);
43
+ expect(shouldActuallyFile({isTTY: true, commit: true, dryRun: true})).toBe(
44
+ false,
45
+ );
46
46
  });
47
47
  });
48
48
 
@@ -54,7 +54,9 @@ describe('formatPreview', () => {
54
54
  title: '[gap] Button: missing compact variant',
55
55
  body: '## User Intention\n\nNeed a compact variant',
56
56
  });
57
- expect(out).toContain('Would file GitHub issue on facebookexperimental/xds');
57
+ expect(out).toContain(
58
+ 'Would file GitHub issue on facebookexperimental/xds',
59
+ );
58
60
  expect(out).toContain('[gap] Button');
59
61
  expect(out).toContain('Need a compact variant');
60
62
  expect(out).toContain('Labels: gap-report');
@@ -91,13 +93,14 @@ describe('ASTRYX_GAP_REPORT=off env var', () => {
91
93
  it('disables gap reporting when set to "off"', async () => {
92
94
  process.env.ASTRYX_GAP_REPORT = 'off';
93
95
  vi.resetModules();
94
- const {loadGapReportConfig, createGapReport} = await import(
95
- '../utils/github.mjs'
96
- );
97
- expect(loadGapReportConfig().enabled).toBe(false);
96
+ const {loadGapReportConfig, createGapReport} =
97
+ await import('../utils/github.mjs');
98
+ await expect(loadGapReportConfig()).resolves.toMatchObject({
99
+ enabled: false,
100
+ });
98
101
 
99
102
  // createGapReport must return null and never invoke gh.
100
- const result = createGapReport({
103
+ const result = await createGapReport({
101
104
  component: 'Button',
102
105
  category: 'other',
103
106
  intention: 'test',
@@ -109,12 +112,16 @@ describe('ASTRYX_GAP_REPORT=off env var', () => {
109
112
  process.env.ASTRYX_GAP_REPORT = 'false';
110
113
  vi.resetModules();
111
114
  let mod = await import('../utils/github.mjs');
112
- expect(mod.loadGapReportConfig().enabled).toBe(false);
115
+ await expect(mod.loadGapReportConfig()).resolves.toMatchObject({
116
+ enabled: false,
117
+ });
113
118
 
114
119
  process.env.ASTRYX_GAP_REPORT = '0';
115
120
  vi.resetModules();
116
121
  mod = await import('../utils/github.mjs');
117
- expect(mod.loadGapReportConfig().enabled).toBe(false);
122
+ await expect(mod.loadGapReportConfig()).resolves.toMatchObject({
123
+ enabled: false,
124
+ });
118
125
  });
119
126
  });
120
127
 
@@ -133,16 +140,14 @@ describe('buildGapReportPreview', () => {
133
140
  it('renders title and body without invoking gh', async () => {
134
141
  vi.resetModules();
135
142
  const {buildGapReportPreview} = await import('../utils/github.mjs');
136
- const preview = buildGapReportPreview({
143
+ const preview = await buildGapReportPreview({
137
144
  component: 'Button',
138
145
  category: 'missing_variant',
139
146
  intention: 'Need compact variant for tables',
140
147
  });
141
148
  expect(preview.mode).toBe('github');
142
149
  expect(preview.enabled).toBe(true);
143
- expect(preview.title).toBe(
144
- '[gap] Button: Need compact variant for tables',
145
- );
150
+ expect(preview.title).toBe('[gap] Button: Need compact variant for tables');
146
151
  expect(preview.body).toContain('| **Component** | Button |');
147
152
  expect(preview.body).toContain('Need compact variant for tables');
148
153
  expect(preview.repo).toBe('facebookexperimental/xds');
@@ -152,7 +157,7 @@ describe('buildGapReportPreview', () => {
152
157
  process.env.ASTRYX_GAP_REPORT = 'off';
153
158
  vi.resetModules();
154
159
  const {buildGapReportPreview} = await import('../utils/github.mjs');
155
- const preview = buildGapReportPreview({
160
+ const preview = await buildGapReportPreview({
156
161
  component: 'Button',
157
162
  category: 'other',
158
163
  intention: 'x',
@@ -29,6 +29,37 @@ import {requireInteractive} from '../utils/interactive.mjs';
29
29
  const VALID_FEATURES = ['agents', 'theme', 'template'];
30
30
  const run = getRunPrefix();
31
31
 
32
+ /**
33
+ * Build the "Next steps" lines printed at the end of `astryx init`.
34
+ *
35
+ * Theme guidance must match the runtime recommendation emitted by core's
36
+ * <Theme> component (packages/core/src/theme/Theme.tsx): the pre-built theme
37
+ * path (`/built` import + `theme.css`) plus the base CSS import, so users
38
+ * don't end up with an unstyled app or the slower runtime style-injection
39
+ * path. See https://github.com/facebook/astryx/issues/3080.
40
+ *
41
+ * Exported for testing.
42
+ *
43
+ * @param {string} runPrefix package-manager run prefix (e.g. `npx`)
44
+ * @returns {string[]} ordered list of human-facing lines
45
+ */
46
+ export function getNextSteps(runPrefix) {
47
+ return [
48
+ '',
49
+ ' Next steps:',
50
+ " 1. Import base styles: import '@astryxdesign/core/reset.css'",
51
+ " and import '@astryxdesign/core/astryx.css'",
52
+ " 2. Import components: import { Button } from '@astryxdesign/core'",
53
+ ' 3. Optionally add a theme (use the pre-built path for performance):',
54
+ " import { neutralTheme } from '@astryxdesign/theme-neutral/built'",
55
+ " import '@astryxdesign/theme-neutral/theme.css'",
56
+ ' <Theme theme={neutralTheme}>...</Theme>',
57
+ ` For custom themes, run \`${runPrefix} astryx theme build <file>\` to generate the built artifacts.`,
58
+ ` 4. ${runPrefix} astryx --help for all commands`,
59
+ '',
60
+ ];
61
+ }
62
+
32
63
  function isCancel(value) {
33
64
  if (p.isCancel(value)) {
34
65
  p.cancel('Setup cancelled.');
@@ -250,13 +281,8 @@ export function registerInit(program) {
250
281
  // Outro
251
282
  p.outro('Design system initialized!');
252
283
 
253
- humanLog('');
254
- humanLog(' Next steps:');
255
- humanLog(" 1. Import components: import { Button } from '@astryxdesign/core'");
256
- humanLog(' 2. Optionally add a theme:');
257
- humanLog(" import { neutralTheme } from '@astryxdesign/theme-neutral'");
258
- humanLog(' <Theme theme={neutralTheme}>...</Theme>');
259
- humanLog(` 3. ${run} astryx --help for all commands`);
260
- humanLog('');
284
+ for (const line of getNextSteps(run)) {
285
+ humanLog(line);
286
+ }
261
287
  });
262
288
  }
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Regression test for `astryx init` "Next steps" theme guidance.
5
+ *
6
+ * The init command previously steered users toward the slower runtime
7
+ * style-injection path:
8
+ *
9
+ * import { neutralTheme } from '@astryxdesign/theme-neutral'
10
+ * <Theme theme={neutralTheme}>...</Theme>
11
+ *
12
+ * ...which contradicted the runtime console warning emitted by core's
13
+ * <Theme> component (packages/core/src/theme/Theme.tsx) recommending the
14
+ * pre-built path, and left users with an unstyled app because the base CSS
15
+ * imports were never mentioned (facebook/astryx#3080).
16
+ *
17
+ * These assertions lock in the corrected guidance: base CSS imports, the
18
+ * pre-built (`/built` + `theme.css`) theme path, and the custom-theme build
19
+ * command.
20
+ */
21
+
22
+ import {describe, it, expect} from 'vitest';
23
+ import {getNextSteps} from './init.mjs';
24
+
25
+ describe('init Next steps theme guidance', () => {
26
+ const text = getNextSteps('npx').join('\n');
27
+
28
+ it('mentions the base CSS imports so the app is not left unstyled', () => {
29
+ expect(text).toContain("'@astryxdesign/core/reset.css'");
30
+ expect(text).toContain("'@astryxdesign/core/astryx.css'");
31
+ });
32
+
33
+ it('uses the pre-built theme path matching the runtime recommendation', () => {
34
+ expect(text).toContain("'@astryxdesign/theme-neutral/built'");
35
+ expect(text).toContain("'@astryxdesign/theme-neutral/theme.css'");
36
+ });
37
+
38
+ it('mentions building custom themes via `astryx theme build`', () => {
39
+ expect(text).toContain('astryx theme build <file>');
40
+ });
41
+
42
+ it('does not steer users to the runtime style-injection import', () => {
43
+ // The bare source import (no `/built`) is the slow runtime-injection path.
44
+ expect(text).not.toContain("from '@astryxdesign/theme-neutral'");
45
+ });
46
+ });
@@ -0,0 +1,139 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file layout command — thin CLI wrapper around api/layout.mjs.
5
+ *
6
+ * Subcommands:
7
+ * astryx layout expand "<expr>" [path] compressed expression → validated TSX
8
+ * astryx layout check "<expr>" validate + echo both canonical surfaces
9
+ * astryx layout grammar agent cheatsheet (alias table is branch-generated)
10
+ *
11
+ * The expression argument may also come from --file or stdin (`-`),
12
+ * which is how multi-line outline (XLO) input usually arrives.
13
+ */
14
+
15
+ import * as fs from 'node:fs';
16
+ import {jsonOut, humanLog} from '../lib/json.mjs';
17
+ import {cliError} from '../lib/cli-error.mjs';
18
+ import {layoutExpand, layoutCheck, layoutGrammar} from '../api/layout.mjs';
19
+
20
+ /** Resolve the expression from arg, --file, or stdin ('-'). */
21
+ async function readExpression(expr, options) {
22
+ if (options.file) return fs.readFileSync(options.file, 'utf-8');
23
+ if (expr === '-' || (!expr && !process.stdin.isTTY)) {
24
+ const chunks = [];
25
+ for await (const chunk of process.stdin) chunks.push(chunk);
26
+ return Buffer.concat(chunks).toString('utf-8');
27
+ }
28
+ return expr;
29
+ }
30
+
31
+ export function registerLayout(program) {
32
+ const layoutCmd = program
33
+ .command('layout')
34
+ .description('Generate XDS layouts from compressed expressions (XLE/XLO)');
35
+
36
+ layoutCmd
37
+ .command('expand [expression] [path]')
38
+ .description('Expand a layout expression into validated XDS TSX')
39
+ .option('--file <file>', 'Read the expression from a file')
40
+ .option('--form <form>', 'Input surface: compact, outline, or auto', 'auto')
41
+ .option('--name <name>', 'Generated component name (PascalCase)', 'GeneratedLayout')
42
+ .option('--loose', 'Downgrade unknown {block} hints to TODO placeholders')
43
+ .action(async (expression, targetPath, options) => {
44
+ const json = program.opts().json || false;
45
+ const source = await readExpression(expression, options);
46
+ if (!source || source.trim() === '') {
47
+ cliError('No layout expression given — pass it as an argument, via --file, or on stdin');
48
+ return;
49
+ }
50
+ let result;
51
+ try {
52
+ result = await layoutExpand(source, {
53
+ targetPath,
54
+ form: options.form,
55
+ loose: options.loose || false,
56
+ name: options.name,
57
+ cwd: process.cwd(),
58
+ });
59
+ } catch (e) {
60
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
61
+ return;
62
+ }
63
+ if (json) return jsonOut(result.type, result.data);
64
+
65
+ for (const warning of result.data.warnings) humanLog(`⚠ ${warning}`);
66
+ if (result.data.written) {
67
+ humanLog(`\n✓ Expanded to ${result.data.written}`);
68
+ humanLog(` Components: ${result.data.componentsUsed.join(', ')}`);
69
+ if (result.data.todos.length > 0) {
70
+ humanLog(` TODOs: ${result.data.todos.length} (search for "TODO(xle)")`);
71
+ }
72
+ humanLog('');
73
+ } else {
74
+ humanLog(result.data.code);
75
+ }
76
+ });
77
+
78
+ layoutCmd
79
+ .command('check [expression]')
80
+ .description('Validate a layout expression and echo canonical compact/outline forms')
81
+ .option('--file <file>', 'Read the expression from a file')
82
+ .option('--form <form>', 'Input surface: compact, outline, or auto', 'auto')
83
+ .option('--loose', 'Downgrade unknown {block} hints to TODO placeholders')
84
+ .action(async (expression, options) => {
85
+ const json = program.opts().json || false;
86
+ const source = await readExpression(expression, options);
87
+ if (!source || source.trim() === '') {
88
+ cliError('No layout expression given — pass it as an argument, via --file, or on stdin');
89
+ return;
90
+ }
91
+ let result;
92
+ try {
93
+ result = await layoutCheck(source, {
94
+ form: options.form,
95
+ loose: options.loose || false,
96
+ cwd: process.cwd(),
97
+ });
98
+ } catch (e) {
99
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
100
+ return;
101
+ }
102
+ if (json) return jsonOut(result.type, result.data);
103
+
104
+ const {valid, form, errors, warnings, compact, outline} = result.data;
105
+ if (!valid) {
106
+ humanLog(`\n✗ Invalid (${errors.length} error${errors.length === 1 ? '' : 's'}):`);
107
+ for (const e of errors) {
108
+ humanLog(` - ${e.formatted}`);
109
+ if (e.suggestions?.length > 0) humanLog(` did you mean: ${e.suggestions.join(', ')}?`);
110
+ }
111
+ humanLog('');
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ humanLog(`\n✓ Valid (parsed as ${form})`);
116
+ for (const warning of warnings) humanLog(`⚠ ${warning}`);
117
+ humanLog('\ncompact:');
118
+ humanLog(` ${compact}`);
119
+ humanLog('\noutline:');
120
+ humanLog(outline.split('\n').map(l => ` ${l}`).join('\n'));
121
+ humanLog('');
122
+ });
123
+
124
+ layoutCmd
125
+ .command('grammar')
126
+ .description('Print the XLE/XLO cheatsheet (alias table generated from this branch)')
127
+ .action(async () => {
128
+ const json = program.opts().json || false;
129
+ let result;
130
+ try {
131
+ result = await layoutGrammar({cwd: process.cwd()});
132
+ } catch (e) {
133
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
134
+ return;
135
+ }
136
+ if (json) return jsonOut(result.type, result.data);
137
+ humanLog(result.data.text);
138
+ });
139
+ }
@@ -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 ?? []) {