@astryxdesign/cli 0.1.2-canary.b712653 → 0.1.2-canary.bbf77ea

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 (111) hide show
  1. package/CHANGELOG.md +0 -1
  2. package/README.md +49 -55
  3. package/docs/theme.doc.mjs +2 -2
  4. package/package.json +19 -7
  5. package/src/api/component.mjs +253 -12
  6. package/src/api/discover.mjs +14 -7
  7. package/src/api/doctor.mjs +10 -25
  8. package/src/api/layout.mjs +11 -10
  9. package/src/api/layout.test.mjs +4 -1
  10. package/src/api/template-integration.test.mjs +225 -0
  11. package/src/api/template.mjs +242 -84
  12. package/src/api/validate-integration.mjs +370 -0
  13. package/src/api/validate-integration.test.mjs +222 -0
  14. package/src/codemod.mjs +93 -0
  15. package/src/codemod.test.mjs +134 -0
  16. package/src/codemods/__tests__/registry.test.mjs +1 -0
  17. package/src/codemods/__tests__/runner.test.mjs +103 -0
  18. package/src/codemods/integration-discovery.mjs +168 -0
  19. package/src/codemods/integration-discovery.test.mjs +234 -0
  20. package/src/codemods/integration-runner.mjs +109 -0
  21. package/src/codemods/registry.mjs +1 -0
  22. package/src/codemods/run-codemod.mjs +207 -0
  23. package/src/codemods/runner.mjs +73 -96
  24. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  25. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-css-surfaces.test.mjs +67 -0
  26. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
  27. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +104 -0
  28. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  29. package/src/codemods/transforms/v0.1.0/index.mjs +43 -6
  30. package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
  31. package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -0
  32. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  33. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  34. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  35. package/src/commands/agent-docs.mjs +1 -1
  36. package/src/commands/component/index.mjs +44 -10
  37. package/src/commands/component-ownership.test.mjs +227 -0
  38. package/src/commands/discover.mjs +5 -16
  39. package/src/commands/doctor.test.mjs +3 -3
  40. package/src/commands/json-contract.test.mjs +0 -32
  41. package/src/commands/swizzle.mjs +224 -235
  42. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  43. package/src/commands/swizzle.routing.test.mjs +279 -0
  44. package/src/commands/template.mjs +33 -37
  45. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  46. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  47. package/src/commands/upgrade.mjs +349 -170
  48. package/src/commands/validate-integration.mjs +110 -0
  49. package/src/commands/validate-integration.test.mjs +124 -0
  50. package/src/config.mjs +7 -20
  51. package/src/config.test.mjs +89 -13
  52. package/src/index.mjs +7 -3
  53. package/src/integration.mjs +19 -0
  54. package/src/lib/component-discovery.mjs +177 -0
  55. package/src/lib/config-cache.mjs +91 -0
  56. package/src/lib/config-cache.test.mjs +83 -0
  57. package/src/lib/config-schema.mjs +41 -55
  58. package/src/lib/error-codes.mjs +7 -12
  59. package/src/lib/integration-warnings.mjs +62 -0
  60. package/src/lib/integration-warnings.test.mjs +102 -0
  61. package/src/lib/integrations.mjs +92 -115
  62. package/src/lib/integrations.test.mjs +88 -107
  63. package/src/lib/manifest.mjs +5 -2
  64. package/src/lib/module-loader.mjs +80 -0
  65. package/src/lib/module-loader.test.mjs +106 -0
  66. package/src/lib/project.mjs +502 -0
  67. package/src/lib/project.test.mjs +308 -0
  68. package/src/template.mjs +73 -0
  69. package/src/template.test.mjs +127 -0
  70. package/src/types/api.d.ts +2 -6
  71. package/src/types/base.d.ts +3 -9
  72. package/src/types/codemod.d.ts +81 -0
  73. package/src/types/component.d.ts +27 -2
  74. package/src/types/config.d.ts +56 -85
  75. package/src/types/error-codes.d.ts +5 -3
  76. package/src/types/index.d.ts +0 -1
  77. package/src/types/integration.d.ts +29 -0
  78. package/src/types/swizzle.d.ts +9 -2
  79. package/src/types/template-api.d.ts +54 -0
  80. package/src/types/template.d.ts +10 -7
  81. package/src/types/upgrade.d.ts +29 -0
  82. package/src/types/validate-integration.d.ts +24 -0
  83. package/src/utils/github.mjs +0 -237
  84. package/src/utils/interactive.mjs +2 -2
  85. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  86. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  87. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  88. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  89. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  90. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  91. package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -1
  92. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +0 -1
  93. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  94. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  95. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +1 -1
  96. package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -1
  97. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  98. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  99. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +1 -1
  100. package/templates/pages/shell-side-nav/page.tsx +0 -1
  101. package/templates/pages/shell-side-nav/template.doc.mjs +1 -1
  102. package/templates/pages/shell-top-nav/template.doc.mjs +1 -1
  103. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  104. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  105. package/src/commands/gap-report.mjs +0 -464
  106. package/src/commands/gap-report.test.mjs +0 -168
  107. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  108. package/src/lib/config.mjs +0 -113
  109. package/src/lib/config.test.mjs +0 -91
  110. package/src/types/gap-report.d.ts +0 -29
  111. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -4,7 +4,7 @@
4
4
  * @file Programmatic API for the discover command.
5
5
  */
6
6
 
7
- import {loadConfig} from '../lib/config.mjs';
7
+ import {Project} from '../lib/project.mjs';
8
8
  import {
9
9
  scanAllPackages,
10
10
  findComponentInPackages,
@@ -40,7 +40,7 @@ function validateDocs(docs) {
40
40
  */
41
41
  export async function discover(query, options = {}) {
42
42
  const {lang = null, zh = false} = options;
43
- const config = await loadConfig();
43
+ const project = await Project.load();
44
44
  const toEntry = pkg => ({
45
45
  name: pkg.name,
46
46
  category: pkg.category,
@@ -50,14 +50,21 @@ export async function discover(query, options = {}) {
50
50
  displayName: pkg.displayName,
51
51
  });
52
52
 
53
- const explicitPackages = (config.loadedIntegrations ?? [])
54
- .map(integration => integration.package)
55
- .filter(Boolean);
56
- if (config.packages.length === 0 && explicitPackages.length === 0) {
53
+ // External packages come from configured integrations that declare a
54
+ // components root. Each becomes a scannable package keyed by its docsDir.
55
+ const explicitPackages = project.loadedIntegrations
56
+ .filter(integration => integration.components)
57
+ .map(integration => ({
58
+ name: integration.name,
59
+ version: integration.version,
60
+ category: integration.name,
61
+ docsDir: integration.components,
62
+ }));
63
+ if (explicitPackages.length === 0) {
57
64
  return {type: 'discover.list', data: [], meta: {configured: false}};
58
65
  }
59
66
 
60
- const packages = scanAllPackages(config.packages, explicitPackages);
67
+ const packages = scanAllPackages([], explicitPackages);
61
68
 
62
69
  if (packages.length === 0) {
63
70
  return {type: 'discover.list', data: [], meta: {configured: true}};
@@ -27,7 +27,7 @@ import {createRequire} from 'node:module';
27
27
  import {MIN_NODE_VERSION, isNodeVersionSupported} from '../lib/node-version.mjs';
28
28
  import {CLI_ROOT, findCoreDir} from '../utils/paths.mjs';
29
29
  import {detectPackageManager} from '../utils/package-manager.mjs';
30
- import {findConfigPath, loadConfig} from '../lib/config.mjs';
30
+ import {findConfigPath, Project} from '../lib/project.mjs';
31
31
  import {semverCompare} from '../utils/semver.mjs';
32
32
 
33
33
  const _require = createRequire(import.meta.url);
@@ -240,7 +240,7 @@ export function checkThemes(ctx) {
240
240
  label: 'Theme packages',
241
241
  status: 'warn',
242
242
  message: 'No @astryxdesign/theme-* packages are installed.',
243
- fix: 'Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set xds.theme.',
243
+ fix: 'Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set astryx.theme.',
244
244
  };
245
245
  }
246
246
 
@@ -279,8 +279,9 @@ export async function checkConfig(ctx) {
279
279
  };
280
280
  }
281
281
 
282
- // loadConfig swallows errors and returns defaults, so re-import directly to
283
- // surface a genuine load failure as a FAIL.
282
+ // Project.load swallows nothing — it surfaces a genuine load failure — but
283
+ // the config check wants to report a bad default export precisely, so we
284
+ // re-import directly to surface a genuine load failure as a FAIL.
284
285
  try {
285
286
  const {pathToFileURL} = await import('node:url');
286
287
  const mod = await import(pathToFileURL(ctx.configPath).href);
@@ -291,25 +292,9 @@ export async function checkConfig(ctx) {
291
292
  label: 'astryx.config.mjs',
292
293
  status: 'fail',
293
294
  message: `astryx.config.mjs default export is not an object (got ${typeof config}).`,
294
- fix: 'Export a default object from astryx.config.mjs, e.g. `export default { theme: "default" };`.',
295
+ fix: 'Export a default object from astryx.config.mjs, e.g. `export default { integrations: [] };`.',
295
296
  };
296
297
  }
297
- // Validate that `packages`, if present, is a string or array of strings.
298
- if (config?.packages !== undefined) {
299
- const arr = Array.isArray(config.packages)
300
- ? config.packages
301
- : [config.packages];
302
- const bad = arr.some(p => typeof p !== 'string');
303
- if (bad) {
304
- return {
305
- id: 'config',
306
- label: 'astryx.config.mjs',
307
- status: 'fail',
308
- message: 'astryx.config.mjs `packages` must be a string or array of strings.',
309
- fix: 'Set `packages` to a path string or array of path strings.',
310
- };
311
- }
312
- }
313
298
  return {
314
299
  id: 'config',
315
300
  label: 'astryx.config.mjs',
@@ -369,7 +354,7 @@ export function checkAgentDocs(ctx) {
369
354
  label: 'AI agent docs',
370
355
  status: 'warn',
371
356
  message: `Agent docs present (${present.join(', ')}) but no Astryx section markers found.`,
372
- fix: 'Add the XDS section to your agent docs with `astryx init --features agents`.',
357
+ fix: 'Add the Astryx section to your agent docs with `astryx init --features agents`.',
373
358
  };
374
359
  }
375
360
 
@@ -377,7 +362,7 @@ export function checkAgentDocs(ctx) {
377
362
  id: 'agent-docs',
378
363
  label: 'AI agent docs',
379
364
  status: 'pass',
380
- message: `XDS agent docs section present in ${withMarkers.join(', ')}.`,
365
+ message: `Astryx agent docs section present in ${withMarkers.join(', ')}.`,
381
366
  };
382
367
  }
383
368
 
@@ -493,8 +478,8 @@ export async function runChecks(options = {}) {
493
478
  // Resolve a possible theme key from config (best-effort; never throws).
494
479
  let configTheme = null;
495
480
  try {
496
- const loaded = await loadConfig(cwd);
497
- configTheme = loaded?.theme ?? null;
481
+ const project = await Project.load(cwd);
482
+ configTheme = project.config?.theme ?? null;
498
483
  } catch {
499
484
  // Best-effort: a missing/invalid config leaves configTheme null.
500
485
  }
@@ -25,13 +25,14 @@ import {expand} from '../lib/xle/expand.mjs';
25
25
  import {toCompact, toOutline} from '../lib/xle/print.mjs';
26
26
  import {buildRegistry, ALIAS_TABLE} from '../lib/xle/registry.mjs';
27
27
  import {discoverTemplates, stripTemplateAssetRefs} from './template.mjs';
28
- import {loadConfig} from '../lib/config.mjs';
28
+ import {Project} from '../lib/project.mjs';
29
29
 
30
30
  /**
31
31
  * The catalog a `{hint}` can resolve to: template blocks (spliced inline) plus
32
- * any app-registered local components from astryx.config.mjs `layout.components`
33
- * (imported by name). App components are how XLE reaches domain pieces — the
34
- * KpiCard/chart/drawer set that the @astryxdesign/core registry can't see.
32
+ * any app-registered local components from astryx.config.mjs
33
+ * `experimental.xle.components` (imported by name). App components are how XLE
34
+ * reaches domain pieces — the KpiCard/chart/drawer set that the
35
+ * @astryxdesign/core registry can't see.
35
36
  */
36
37
  async function loadBlocks(cwd) {
37
38
  const blocks = [];
@@ -42,20 +43,20 @@ async function loadBlocks(cwd) {
42
43
  // discovery is best-effort
43
44
  }
44
45
  try {
45
- const config = await loadConfig(cwd);
46
- const components = config.layout?.components || {};
46
+ const project = await Project.load(cwd);
47
+ const components = project.config.experimental?.xle?.components ?? {};
47
48
  for (const [name, spec] of Object.entries(components)) {
48
- const importPath = typeof spec === 'string' ? spec : spec.from;
49
+ const importPath = spec.from;
49
50
  if (!importPath) continue;
50
51
  blocks.push({
51
52
  type: 'block',
52
53
  kind: 'component',
53
54
  dirName: name,
54
55
  name,
55
- description: typeof spec === 'object' ? spec.description || '' : '',
56
+ description: spec.description ?? '',
56
57
  category: 'app',
57
58
  importPath,
58
- isDefault: typeof spec === 'object' ? Boolean(spec.default) : false,
59
+ isDefault: Boolean(spec.default),
59
60
  });
60
61
  }
61
62
  } catch {
@@ -281,7 +282,7 @@ TEMPLATE REFERENCING ({hint} pulls in real content — this is how XLE reaches
281
282
  {kpi-card} standalone reference (no wrapper element) — place a component directly
282
283
  {kpi-card}*4 repeat a reference; the definition/import is emitted once
283
284
  app components register local ones in astryx.config.mjs to import them by name:
284
- export default {layout: {components: {KpiCard: '@/components/KpiCard'}}}
285
+ export default {experimental: {xle: {components: {KpiCard: {from: '@/components/KpiCard'}}}}}
285
286
  then {kpi-card} → import {KpiCard} + <KpiCard /> (kebab ↔ Pascal)
286
287
 
287
288
  STRUCTURE THE EXPANDER HANDLES
@@ -196,11 +196,14 @@ describe('template referencing', () => {
196
196
 
197
197
  it('imports app-registered local components (the local-component bridge)', async () => {
198
198
  // Inside the workspace so @astryxdesign/core resolves; cleaned up after.
199
+ // A package.json beside the config makes Project.load resolve it as the
200
+ // sibling-of-nearest-package.json (the standard config resolution).
199
201
  const cwd = mkdtempSync(join(process.cwd(), '.xle-imp-test-'));
200
202
  try {
203
+ writeFileSync(join(cwd, 'package.json'), '{"name": "xle-imp-fixture"}\n');
201
204
  writeFileSync(
202
205
  join(cwd, 'astryx.config.mjs'),
203
- `export default {layout: {components: {KpiCard: '@/components/KpiCard', TimeRangePicker: {from: '@/components/TimeRangePicker'}}}};\n`,
206
+ `export default {experimental: {xle: {components: {KpiCard: {from: '@/components/KpiCard'}, TimeRangePicker: {from: '@/components/TimeRangePicker'}}}}};\n`,
204
207
  );
205
208
  const result = await layoutExpand('S[p6] > (G[c4 g4] > {kpi-card}*4) + {time-range-picker}', {
206
209
  name: 'Demo',
@@ -0,0 +1,225 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration-provided template discovery (same-stem + type-driven).
5
+ *
6
+ * These tests stand up a temp consumer project with an astryx.config and an
7
+ * installed integration package that contributes templates, then exercise the
8
+ * public `template()` API to verify discovery, package/type scoping, ambiguity
9
+ * errors, and copy-to-dir naming.
10
+ */
11
+
12
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import {template} from './template.mjs';
16
+
17
+ let tmpDir;
18
+ let originalCwd;
19
+
20
+ /** Absolute path to the CLI package so integrations can import /template. */
21
+ const CLI_PKG = path.resolve(import.meta.dirname, '..', '..');
22
+
23
+ function makeConsumer() {
24
+ const dir = fs.mkdtempSync(
25
+ path.join(process.cwd(), '.astryx-template-it-'),
26
+ );
27
+ fs.writeFileSync(
28
+ path.join(dir, 'package.json'),
29
+ JSON.stringify({name: 'consumer'}),
30
+ );
31
+ fs.writeFileSync(
32
+ path.join(dir, 'astryx.config.mjs'),
33
+ `export default { integrations: ['@acme/widgets'] };\n`,
34
+ );
35
+ return dir;
36
+ }
37
+
38
+ /**
39
+ * Install an @acme/widgets integration package that declares a templates root.
40
+ * @returns the package dir.
41
+ */
42
+ function installWidgets(consumerDir) {
43
+ const pkgDir = path.join(consumerDir, 'node_modules', '@acme', 'widgets');
44
+ fs.mkdirSync(pkgDir, {recursive: true});
45
+ fs.writeFileSync(
46
+ path.join(pkgDir, 'package.json'),
47
+ JSON.stringify({name: '@acme/widgets', version: '2.0.0'}),
48
+ );
49
+ fs.writeFileSync(
50
+ path.join(pkgDir, 'astryx.integration.mjs'),
51
+ `export default { templates: './templates' };\n`,
52
+ );
53
+ fs.mkdirSync(path.join(pkgDir, 'templates'));
54
+ return pkgDir;
55
+ }
56
+
57
+ /** Write a template doc + same-stem source under the templates root. */
58
+ function writeTemplate(pkgDir, id, {kind, body, withSource = true}) {
59
+ const docPath = path.join(pkgDir, 'templates', `${id}.doc.mjs`);
60
+ fs.mkdirSync(path.dirname(docPath), {recursive: true});
61
+ const create = kind === 'page' ? 'createPageTemplate' : 'createBlockTemplate';
62
+ fs.writeFileSync(
63
+ docPath,
64
+ body ??
65
+ `import {${create}} from '${CLI_PKG}/src/template.mjs';\n` +
66
+ `export default ${create}({name: '${id} name', description: '${id} desc'});\n`,
67
+ );
68
+ if (withSource) {
69
+ fs.writeFileSync(
70
+ path.join(pkgDir, 'templates', `${id}.tsx`),
71
+ `export default function ${id.replace(/[^a-zA-Z0-9]/g, '')}() { return null; }\n`,
72
+ );
73
+ }
74
+ }
75
+
76
+ beforeEach(() => {
77
+ originalCwd = process.cwd();
78
+ tmpDir = makeConsumer();
79
+ process.chdir(tmpDir);
80
+ });
81
+
82
+ afterEach(() => {
83
+ process.chdir(originalCwd);
84
+ fs.rmSync(tmpDir, {recursive: true, force: true});
85
+ });
86
+
87
+ describe('integration template discovery', () => {
88
+ it('discovers and lists an integration template with package + type', async () => {
89
+ const pkgDir = installWidgets(tmpDir);
90
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
91
+
92
+ const result = await template(undefined, {list: true, cwd: tmpDir});
93
+ expect(result.type).toBe('template.list');
94
+ const entry = result.data.find(t => t.id === 'pricing');
95
+ expect(entry).toBeTruthy();
96
+ expect(entry.type).toBe('page');
97
+ expect(entry.package).toBe('@acme/widgets');
98
+ expect(entry.name).toBe('pricing name');
99
+ expect(entry.description).toBe('pricing desc');
100
+ });
101
+
102
+ it('lists nested-id templates (kebab path under root)', async () => {
103
+ const pkgDir = installWidgets(tmpDir);
104
+ writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
105
+
106
+ const result = await template(undefined, {list: true, cwd: tmpDir});
107
+ const entry = result.data.find(t => t.id === 'marketing/hero');
108
+ expect(entry).toBeTruthy();
109
+ expect(entry.type).toBe('block');
110
+ expect(entry.package).toBe('@acme/widgets');
111
+ });
112
+
113
+ it('always reports core templates under @astryxdesign/core', async () => {
114
+ const result = await template(undefined, {list: true, cwd: tmpDir});
115
+ const core = result.data.filter(t => t.package === '@astryxdesign/core');
116
+ expect(core.length).toBeGreaterThan(0);
117
+ });
118
+
119
+ it('--package narrows the listing', async () => {
120
+ const pkgDir = installWidgets(tmpDir);
121
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
122
+
123
+ const result = await template(undefined, {
124
+ list: true,
125
+ package: '@acme/widgets',
126
+ cwd: tmpDir,
127
+ });
128
+ expect(result.data.length).toBe(1);
129
+ expect(result.data[0].id).toBe('pricing');
130
+ });
131
+
132
+ it('skips a template whose same-stem source is missing', async () => {
133
+ const pkgDir = installWidgets(tmpDir);
134
+ writeTemplate(pkgDir, 'orphan', {kind: 'page', withSource: false});
135
+
136
+ const result = await template(undefined, {list: true, cwd: tmpDir});
137
+ expect(result.data.find(t => t.id === 'orphan')).toBeUndefined();
138
+ });
139
+
140
+ it('skips a raw doc that is missing a type', async () => {
141
+ const pkgDir = installWidgets(tmpDir);
142
+ writeTemplate(pkgDir, 'untyped', {
143
+ kind: 'page',
144
+ body: `export default {name: 'Untyped', description: 'no type'};\n`,
145
+ });
146
+
147
+ const result = await template(undefined, {list: true, cwd: tmpDir});
148
+ expect(result.data.find(t => t.id === 'untyped')).toBeUndefined();
149
+ });
150
+
151
+ it('errors with candidates when an id is ambiguous across type/package', async () => {
152
+ const pkgDir = installWidgets(tmpDir);
153
+ // Same id "hero" as both a page and a block within the integration.
154
+ writeTemplate(pkgDir, 'hero', {kind: 'page'});
155
+ // Add a sibling block doc with the same stem in a different file is not
156
+ // possible (same file). Instead install a second package with a "hero".
157
+ const pkg2 = path.join(tmpDir, 'node_modules', '@acme', 'extra');
158
+ fs.mkdirSync(path.join(pkg2, 'templates'), {recursive: true});
159
+ fs.writeFileSync(
160
+ path.join(pkg2, 'package.json'),
161
+ JSON.stringify({name: '@acme/extra', version: '1.0.0'}),
162
+ );
163
+ fs.writeFileSync(
164
+ path.join(pkg2, 'astryx.integration.mjs'),
165
+ `export default { templates: './templates' };\n`,
166
+ );
167
+ fs.writeFileSync(
168
+ path.join(pkg2, 'templates', 'hero.doc.mjs'),
169
+ `import {createBlockTemplate} from '${CLI_PKG}/src/template.mjs';\n` +
170
+ `export default createBlockTemplate({name: 'Hero block', description: 'b'});\n`,
171
+ );
172
+ fs.writeFileSync(
173
+ path.join(pkg2, 'templates', 'hero.tsx'),
174
+ `export default function Hero() { return null; }\n`,
175
+ );
176
+ fs.writeFileSync(
177
+ path.join(tmpDir, 'astryx.config.mjs'),
178
+ `export default { integrations: ['@acme/widgets', '@acme/extra'] };\n`,
179
+ );
180
+
181
+ await expect(
182
+ template('hero', {targetPath: './out', cwd: tmpDir}),
183
+ ).rejects.toMatchObject({code: 'ERR_AMBIGUOUS_TEMPLATE'});
184
+ });
185
+
186
+ it('--type and --package narrow an ambiguous id to a single match', async () => {
187
+ const pkgDir = installWidgets(tmpDir);
188
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
189
+
190
+ const result = await template('pricing', {
191
+ type: 'page',
192
+ package: '@acme/widgets',
193
+ show: true,
194
+ cwd: tmpDir,
195
+ });
196
+ expect(result.type).toBe('template.show');
197
+ expect(result.data.type).toBe('page');
198
+ });
199
+
200
+ it('copies a page template into a directory as page.tsx', async () => {
201
+ const pkgDir = installWidgets(tmpDir);
202
+ writeTemplate(pkgDir, 'pricing', {kind: 'page'});
203
+
204
+ const result = await template('pricing', {
205
+ targetPath: './dest',
206
+ cwd: tmpDir,
207
+ });
208
+ expect(result.type).toBe('template.copy');
209
+ expect(result.data.fileName).toBe('page.tsx');
210
+ expect(fs.existsSync(path.join(tmpDir, 'dest', 'page.tsx'))).toBe(true);
211
+ });
212
+
213
+ it('copies a block template into a directory as <id-basename>.tsx', async () => {
214
+ const pkgDir = installWidgets(tmpDir);
215
+ writeTemplate(pkgDir, 'marketing/hero', {kind: 'block'});
216
+
217
+ const result = await template('marketing/hero', {
218
+ targetPath: './dest',
219
+ cwd: tmpDir,
220
+ });
221
+ expect(result.type).toBe('template.copy');
222
+ expect(result.data.fileName).toBe('hero.tsx');
223
+ expect(fs.existsSync(path.join(tmpDir, 'dest', 'hero.tsx'))).toBe(true);
224
+ });
225
+ });