@astryxdesign/cli 0.1.1 → 0.1.2-canary.043f3fd

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 (185) 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 +31 -8
  5. package/src/api/component.mjs +253 -12
  6. package/src/api/discover.mjs +87 -28
  7. package/src/api/doctor.mjs +7 -22
  8. package/src/api/index.mjs +1 -0
  9. package/src/api/layout.mjs +302 -0
  10. package/src/api/layout.test.mjs +241 -0
  11. package/src/api/template-integration.test.mjs +225 -0
  12. package/src/api/template.mjs +421 -122
  13. package/src/api/template.test.mjs +2 -0
  14. package/src/api/theme-add.mjs +182 -0
  15. package/src/api/validate-integration.mjs +370 -0
  16. package/src/api/validate-integration.test.mjs +222 -0
  17. package/src/codemod.mjs +93 -0
  18. package/src/codemod.test.mjs +134 -0
  19. package/src/codemods/__tests__/registry.test.mjs +2 -0
  20. package/src/codemods/__tests__/runner.test.mjs +103 -0
  21. package/src/codemods/integration-discovery.mjs +168 -0
  22. package/src/codemods/integration-discovery.test.mjs +234 -0
  23. package/src/codemods/integration-runner.mjs +109 -0
  24. package/src/codemods/registry.mjs +2 -0
  25. package/src/codemods/run-codemod.mjs +207 -0
  26. package/src/codemods/runner.mjs +73 -96
  27. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  28. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +81 -0
  29. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  30. package/src/codemods/transforms/v0.1.0/index.mjs +15 -6
  31. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  32. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  33. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  34. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  35. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  36. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  37. package/src/commands/build-theme.import-path.test.mjs +22 -2
  38. package/src/commands/build-theme.mjs +185 -16
  39. package/src/commands/component/index.mjs +44 -10
  40. package/src/commands/component-ownership.test.mjs +227 -0
  41. package/src/commands/discover.mjs +5 -16
  42. package/src/commands/doctor.test.mjs +3 -3
  43. package/src/commands/init.mjs +34 -8
  44. package/src/commands/init.next-steps.test.mjs +46 -0
  45. package/src/commands/json-contract.test.mjs +0 -32
  46. package/src/commands/layout.mjs +139 -0
  47. package/src/commands/swizzle.mjs +241 -224
  48. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  49. package/src/commands/swizzle.routing.test.mjs +279 -0
  50. package/src/commands/template.mjs +33 -37
  51. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  52. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  53. package/src/commands/upgrade.mjs +349 -239
  54. package/src/commands/validate-integration.mjs +110 -0
  55. package/src/commands/validate-integration.test.mjs +124 -0
  56. package/src/config.mjs +18 -0
  57. package/src/config.test.mjs +100 -0
  58. package/src/index.mjs +13 -3
  59. package/src/integration.mjs +19 -0
  60. package/src/lib/component-discovery.mjs +177 -0
  61. package/src/lib/config-cache.mjs +91 -0
  62. package/src/lib/config-cache.test.mjs +83 -0
  63. package/src/lib/config-schema.mjs +105 -0
  64. package/src/lib/error-codes.mjs +18 -12
  65. package/src/lib/integration-warnings.mjs +62 -0
  66. package/src/lib/integration-warnings.test.mjs +102 -0
  67. package/src/lib/integrations.mjs +132 -0
  68. package/src/lib/integrations.test.mjs +135 -0
  69. package/src/lib/levenshtein.mjs +29 -0
  70. package/src/lib/manifest.mjs +15 -2
  71. package/src/lib/module-loader.mjs +80 -0
  72. package/src/lib/module-loader.test.mjs +106 -0
  73. package/src/lib/package-scanner.mjs +31 -7
  74. package/src/lib/project.mjs +502 -0
  75. package/src/lib/project.test.mjs +308 -0
  76. package/src/lib/string-utils.mjs +5 -14
  77. package/src/lib/xle/browser.d.ts +100 -0
  78. package/src/lib/xle/browser.mjs +120 -0
  79. package/src/lib/xle/expand.mjs +622 -0
  80. package/src/lib/xle/parse.mjs +581 -0
  81. package/src/lib/xle/print.mjs +174 -0
  82. package/src/lib/xle/registry-core.mjs +170 -0
  83. package/src/lib/xle/registry.mjs +237 -0
  84. package/src/lib/xle/splice.mjs +137 -0
  85. package/src/lib/xle/validate.mjs +356 -0
  86. package/src/lib/xle/xle.test.mjs +333 -0
  87. package/src/template.mjs +73 -0
  88. package/src/template.test.mjs +127 -0
  89. package/src/types/api.d.ts +2 -6
  90. package/src/types/base.d.ts +3 -9
  91. package/src/types/codemod.d.ts +81 -0
  92. package/src/types/component.d.ts +27 -2
  93. package/src/types/config.d.ts +70 -0
  94. package/src/types/error-codes.d.ts +5 -3
  95. package/src/types/index.d.ts +0 -1
  96. package/src/types/integration.d.ts +29 -0
  97. package/src/types/swizzle.d.ts +9 -2
  98. package/src/types/template-api.d.ts +54 -0
  99. package/src/types/template.d.ts +10 -7
  100. package/src/types/upgrade.d.ts +29 -0
  101. package/src/types/validate-integration.d.ts +24 -0
  102. package/src/utils/github.mjs +0 -252
  103. package/src/utils/interactive.mjs +2 -2
  104. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  105. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  106. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  107. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  108. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  109. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  110. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  111. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  112. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  113. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  114. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  115. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  116. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  117. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  118. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  119. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  120. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  121. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  122. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  123. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  124. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  125. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  126. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  127. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  128. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  129. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  130. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  131. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  132. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  133. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  134. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  135. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  136. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  137. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  138. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  139. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  140. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  141. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  142. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  143. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  144. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  145. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  146. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  147. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  148. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  149. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  150. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  151. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  152. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  153. package/templates/pages/shell-nav/page.tsx +321 -0
  154. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  155. package/templates/pages/shell-side-nav/page.tsx +242 -0
  156. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  157. package/templates/pages/shell-top-nav/page.tsx +224 -0
  158. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  159. package/templates/pages/theme-showcase/page.tsx +8 -19
  160. package/templates/themes/butter/butterTheme.ts +916 -0
  161. package/templates/themes/butter/icons.tsx +77 -0
  162. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  163. package/templates/themes/chocolate/icons.tsx +77 -0
  164. package/templates/themes/gothic/gothicTheme.ts +657 -0
  165. package/templates/themes/gothic/icons.tsx +77 -0
  166. package/templates/themes/manifest.json +90 -0
  167. package/templates/themes/matcha/icons.tsx +67 -0
  168. package/templates/themes/matcha/matchaTheme.ts +247 -0
  169. package/templates/themes/neutral/icons.tsx +77 -0
  170. package/templates/themes/neutral/neutralTheme.ts +603 -0
  171. package/templates/themes/stone/icons.tsx +77 -0
  172. package/templates/themes/stone/stoneTheme.ts +652 -0
  173. package/templates/themes/y2k/icons.tsx +67 -0
  174. package/templates/themes/y2k/y2kTheme.ts +617 -0
  175. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  176. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  177. package/src/commands/gap-report.mjs +0 -456
  178. package/src/commands/gap-report.test.mjs +0 -163
  179. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  180. package/src/lib/config.mjs +0 -86
  181. package/src/lib/config.test.mjs +0 -42
  182. package/src/types/gap-report.d.ts +0 -29
  183. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  184. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
  185. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -0,0 +1,91 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Pluggable config/discovery cache for the Project API.
5
+ *
6
+ * The Project class memoizes resolved discovery (components, templates,
7
+ * codemods, config) behind a small key/value cache so the same work is never
8
+ * repeated within a single Project instance. The cache interface is
9
+ * intentionally tiny — `get(key)` / `set(key, value)` — so a future
10
+ * file-backed, cross-process cache can drop in with NO API change.
11
+ *
12
+ * Cache keys are derived from a CONTENT HASH of the project's config file
13
+ * (see {@link configContentHash}) combined with the cwd and the
14
+ * discovery-kind (+ args). The in-memory implementation never busts on a
15
+ * content change, but the hash is computed regardless so a hash-busting,
16
+ * file-backed cache works against the exact same key path.
17
+ *
18
+ * @typedef {Object} ConfigCache
19
+ * @property {(key: string) => unknown} get
20
+ * @property {(key: string, value: unknown) => void} set
21
+ */
22
+
23
+ import * as fs from 'node:fs';
24
+ import * as crypto from 'node:crypto';
25
+
26
+ /**
27
+ * Stable sentinel hash used when there is no config file (or it can't be
28
+ * read). Keeps key derivation total — every cwd still produces a key.
29
+ */
30
+ export const NO_CONFIG_HASH = 'no-config';
31
+
32
+ /**
33
+ * Compute a stable content hash for a config file. Reads the file bytes and
34
+ * returns their sha256 hex digest; a missing/unreadable file yields
35
+ * {@link NO_CONFIG_HASH}. The hash is the bust signal a future file-backed
36
+ * cache would key on.
37
+ *
38
+ * @param {string | null | undefined} configPath absolute config path, or null
39
+ * @returns {string}
40
+ */
41
+ export function configContentHash(configPath) {
42
+ if (!configPath) return NO_CONFIG_HASH;
43
+ try {
44
+ const bytes = fs.readFileSync(configPath);
45
+ return crypto.createHash('sha256').update(bytes).digest('hex');
46
+ } catch {
47
+ return NO_CONFIG_HASH;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Build a cache key from a config content hash, a cwd, and a discovery kind
53
+ * (already including any args, e.g. `codemods:0.1.0..0.2.0`). The shape is
54
+ * stable so the same inputs always map to the same key.
55
+ *
56
+ * @param {string} hash config content hash
57
+ * @param {string} cwd
58
+ * @param {string} kind discovery kind (+args)
59
+ * @returns {string}
60
+ */
61
+ export function cacheKey(hash, cwd, kind) {
62
+ return `${hash}\u0000${cwd}\u0000${kind}`;
63
+ }
64
+
65
+ /**
66
+ * Default in-memory cache. Per-instance by default (the Project creates one in
67
+ * {@link Project.load} when none is supplied), so it behaves like simple
68
+ * memoization. A shared instance can be passed to reuse results across
69
+ * Projects. Never busts — a content change produces a different KEY (via the
70
+ * hash), not an eviction, which is exactly the behavior a future file-backed
71
+ * cache also relies on.
72
+ *
73
+ * @implements {ConfigCache}
74
+ */
75
+ export class InMemoryConfigCache {
76
+ /** @type {Map<string, unknown>} */
77
+ #m = new Map();
78
+
79
+ /** @param {string} key */
80
+ get(key) {
81
+ return this.#m.get(key);
82
+ }
83
+
84
+ /**
85
+ * @param {string} key
86
+ * @param {unknown} value
87
+ */
88
+ set(key, value) {
89
+ this.#m.set(key, value);
90
+ }
91
+ }
@@ -0,0 +1,83 @@
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 {
7
+ InMemoryConfigCache,
8
+ cacheKey,
9
+ configContentHash,
10
+ NO_CONFIG_HASH,
11
+ } from './config-cache.mjs';
12
+
13
+ let tmpDir;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-cache-test-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, {recursive: true, force: true});
21
+ });
22
+
23
+ describe('InMemoryConfigCache', () => {
24
+ it('stores and returns values by key', () => {
25
+ const cache = new InMemoryConfigCache();
26
+ expect(cache.get('a')).toBeUndefined();
27
+ cache.set('a', 42);
28
+ expect(cache.get('a')).toBe(42);
29
+ });
30
+
31
+ it('can store falsy values distinctly from a miss', () => {
32
+ const cache = new InMemoryConfigCache();
33
+ cache.set('zero', {value: 0});
34
+ expect(cache.get('zero')).toEqual({value: 0});
35
+ expect(cache.get('missing')).toBeUndefined();
36
+ });
37
+ });
38
+
39
+ describe('configContentHash', () => {
40
+ it('returns the sentinel when there is no config path', () => {
41
+ expect(configContentHash(null)).toBe(NO_CONFIG_HASH);
42
+ expect(configContentHash(undefined)).toBe(NO_CONFIG_HASH);
43
+ });
44
+
45
+ it('returns the sentinel for a missing file', () => {
46
+ expect(configContentHash(path.join(tmpDir, 'nope.mjs'))).toBe(
47
+ NO_CONFIG_HASH,
48
+ );
49
+ });
50
+
51
+ it('is stable for the same bytes', () => {
52
+ const file = path.join(tmpDir, 'astryx.config.mjs');
53
+ fs.writeFileSync(file, 'export default { integrations: [] };\n');
54
+ const a = configContentHash(file);
55
+ const b = configContentHash(file);
56
+ expect(a).toBe(b);
57
+ expect(a).not.toBe(NO_CONFIG_HASH);
58
+ });
59
+
60
+ it('differs when the config content changes', () => {
61
+ const file = path.join(tmpDir, 'astryx.config.mjs');
62
+ fs.writeFileSync(file, 'export default { integrations: [] };\n');
63
+ const before = configContentHash(file);
64
+ fs.writeFileSync(file, 'export default { integrations: ["@x/y"] };\n');
65
+ const after = configContentHash(file);
66
+ expect(before).not.toBe(after);
67
+ });
68
+ });
69
+
70
+ describe('cacheKey', () => {
71
+ it('is stable for identical inputs and varies by kind', () => {
72
+ const k1 = cacheKey('hash', '/cwd', 'components');
73
+ const k2 = cacheKey('hash', '/cwd', 'components');
74
+ const k3 = cacheKey('hash', '/cwd', 'templates');
75
+ expect(k1).toBe(k2);
76
+ expect(k1).not.toBe(k3);
77
+ });
78
+
79
+ it('varies by hash and cwd', () => {
80
+ expect(cacheKey('h1', '/cwd', 'k')).not.toBe(cacheKey('h2', '/cwd', 'k'));
81
+ expect(cacheKey('h', '/a', 'k')).not.toBe(cacheKey('h', '/b', 'k'));
82
+ });
83
+ });
@@ -0,0 +1,105 @@
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
+ export const PostCodemodHookSchema = z
12
+ .object({
13
+ name: z.string().optional(),
14
+ buildCommand: Fn,
15
+ })
16
+ .strict();
17
+
18
+ export const XleComponentSchema = z
19
+ .object({
20
+ from: z.string(),
21
+ description: z.string().optional(),
22
+ default: z.boolean().optional(),
23
+ })
24
+ .strict();
25
+
26
+ export const AstryxConfigSchema = z
27
+ .object({
28
+ integrations: z.array(z.string()).optional(),
29
+ issuesUrl: z.string().url().optional(),
30
+ hooks: z
31
+ .object({
32
+ postCodemod: z.array(PostCodemodHookSchema).optional(),
33
+ })
34
+ .strict()
35
+ .optional(),
36
+ experimental: z
37
+ .object({
38
+ xle: z
39
+ .object({
40
+ components: z.record(z.string(), XleComponentSchema).optional(),
41
+ })
42
+ .strict()
43
+ .optional(),
44
+ })
45
+ .strict()
46
+ .optional(),
47
+ })
48
+ .strict();
49
+
50
+ export const AstryxIntegrationSchema = z
51
+ .object({
52
+ components: z.string().optional(),
53
+ templates: z.string().optional(),
54
+ codemods: z.string().optional(),
55
+ issuesUrl: z.string().url().optional(),
56
+ })
57
+ .strict();
58
+
59
+ /**
60
+ * Format a zod error into a single readable line, mirroring the issue-joining
61
+ * convention used across the CLI (path: message; path: message). Exported so
62
+ * the shared module loader and other validators stay in lockstep.
63
+ * @param {string} label
64
+ * @param {import('zod').ZodError} error
65
+ * @returns {string}
66
+ */
67
+ export function formatZodError(label, error) {
68
+ const issues = error.issues
69
+ .map(issue => {
70
+ const path = issue.path.length ? issue.path.join('.') : '(root)';
71
+ return `${path}: ${issue.message}`;
72
+ })
73
+ .join('; ');
74
+ return `${label} is invalid: ${issues}`;
75
+ }
76
+
77
+ /**
78
+ * @param {unknown} config
79
+ * @returns {import('../types/config').AstryxConfig}
80
+ */
81
+ export function validateConfig(config) {
82
+ const result = AstryxConfigSchema.safeParse(config);
83
+ if (!result.success) {
84
+ throw new Error(
85
+ formatZodError('astryx.config default export', result.error),
86
+ );
87
+ }
88
+ return result.data;
89
+ }
90
+
91
+ /**
92
+ * @param {unknown} integration
93
+ * @param {string} [label]
94
+ * @returns {import('../types/integration').AstryxIntegration}
95
+ */
96
+ export function validateIntegration(
97
+ integration,
98
+ label = 'integration manifest',
99
+ ) {
100
+ const result = AstryxIntegrationSchema.safeParse(integration);
101
+ if (!result.success) {
102
+ throw new Error(formatZodError(label, result.error));
103
+ }
104
+ return result.data;
105
+ }
@@ -52,6 +52,9 @@
52
52
  * | 'ERR_UNKNOWN_SECTION'
53
53
  * | 'ERR_UNKNOWN_CATEGORY'
54
54
  * | 'ERR_UNKNOWN_TEMPLATE'
55
+ * | 'ERR_AMBIGUOUS_TEMPLATE'
56
+ * | 'ERR_AMBIGUOUS_COMPONENT'
57
+ * | 'ERR_UNKNOWN_THEME'
55
58
  * | 'ERR_UNKNOWN_PACKAGE'
56
59
  * | 'ERR_UNKNOWN_AGENT'
57
60
  * | 'ERR_UNKNOWN_FEATURE'
@@ -68,13 +71,12 @@
68
71
  * | 'ERR_WRITE_FAILED'
69
72
  * | 'ERR_THEME_INVALID'
70
73
  * | 'ERR_THEME_LOAD'
71
- * | 'ERR_TEMPLATE_CONFIG'
72
- * | 'ERR_TEMPLATE_GET'
73
74
  * | 'ERR_VERSION_DETECT'
74
75
  * | 'ERR_INVALID_VERSION'
75
76
  * | 'ERR_DEP_MISSING'
76
77
  * | 'ERR_GH_CLI'
77
- * | 'ERR_GAP_REPORT_FAILED'
78
+ * | 'ERR_LAYOUT_PARSE'
79
+ * | 'ERR_LAYOUT_INVALID'
78
80
  * )} ErrorCode
79
81
  */
80
82
 
@@ -122,6 +124,12 @@ export const ERROR_CODES = Object.freeze({
122
124
  ERR_UNKNOWN_CATEGORY: 'ERR_UNKNOWN_CATEGORY',
123
125
  /** No template matched the requested name. */
124
126
  ERR_UNKNOWN_TEMPLATE: 'ERR_UNKNOWN_TEMPLATE',
127
+ /** A template id matched more than one template (narrow with --type/--package). */
128
+ ERR_AMBIGUOUS_TEMPLATE: 'ERR_AMBIGUOUS_TEMPLATE',
129
+ /** A component name is owned by more than one package (narrow with --package). */
130
+ ERR_AMBIGUOUS_COMPONENT: 'ERR_AMBIGUOUS_COMPONENT',
131
+ /** No theme matched the requested slug (theme add). */
132
+ ERR_UNKNOWN_THEME: 'ERR_UNKNOWN_THEME',
125
133
  /** No package matched the requested name (discover). */
126
134
  ERR_UNKNOWN_PACKAGE: 'ERR_UNKNOWN_PACKAGE',
127
135
  /** An unrecognized `--agent` value was passed to agent-docs/init. */
@@ -161,12 +169,6 @@ export const ERROR_CODES = Object.freeze({
161
169
  /** A theme file could not be loaded / parsed into a defineTheme result. */
162
170
  ERR_THEME_LOAD: 'ERR_THEME_LOAD',
163
171
 
164
- // ── Template config ──────────────────────────────────────────────
165
- /** `template.get` is not configured in astryx.config.mjs (fetch-by-id). */
166
- ERR_TEMPLATE_CONFIG: 'ERR_TEMPLATE_CONFIG',
167
- /** A configured `template.get` threw or returned an invalid value. */
168
- ERR_TEMPLATE_GET: 'ERR_TEMPLATE_GET',
169
-
170
172
  // ── Upgrade ──────────────────────────────────────────────────────
171
173
  /** The current `@astryxdesign/core` version could not be detected. */
172
174
  ERR_VERSION_DETECT: 'ERR_VERSION_DETECT',
@@ -175,11 +177,15 @@ export const ERROR_CODES = Object.freeze({
175
177
  /** A required external dependency (e.g. jscodeshift) is missing. */
176
178
  ERR_DEP_MISSING: 'ERR_DEP_MISSING',
177
179
 
178
- // ── Gap report ───────────────────────────────────────────────────
180
+ // ── GitHub CLI ───────────────────────────────────────────────────
179
181
  /** GitHub CLI (`gh`) is not installed or not authenticated. */
180
182
  ERR_GH_CLI: 'ERR_GH_CLI',
181
- /** Filing a gap report failed at the command/integration boundary. */
182
- ERR_GAP_REPORT_FAILED: 'ERR_GAP_REPORT_FAILED',
183
+
184
+ // ── Layout expressions (XLE/XLO) ─────────────────────────────────
185
+ /** A layout expression failed to parse (syntax error, with line/col). */
186
+ ERR_LAYOUT_PARSE: 'ERR_LAYOUT_PARSE',
187
+ /** A layout expression parsed but failed validation (unknown component/prop/enum/block). */
188
+ ERR_LAYOUT_INVALID: 'ERR_LAYOUT_INVALID',
183
189
  });
184
190
 
185
191
  /**
@@ -0,0 +1,62 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Compact, non-blocking integration-issue nudge for everyday commands.
5
+ *
6
+ * When a CONFIGURED integration (from the Project's `loadedIntegrations`) has
7
+ * validation issues, the everyday commands (component / template / upgrade)
8
+ * should print ONE compact, non-blocking line per integration telling the user
9
+ * to run `validate-integration` — instead of silently skipping broken
10
+ * contributions or spamming per-contribution diagnostics.
11
+ *
12
+ * Design constraints (all enforced here):
13
+ * - Reuses the validate-integration validators (validateLoadedIntegration);
14
+ * no validation logic is duplicated.
15
+ * - Writes to STDERR only, so it never corrupts a --json stdout envelope.
16
+ * - Suppressed entirely in --json mode.
17
+ * - Best-effort: never throws, never changes the exit code. Broken
18
+ * contributions are still skipped downstream exactly as before; this only
19
+ * ADDS a one-line nudge.
20
+ */
21
+
22
+ import {validateLoadedIntegration} from '../api/validate-integration.mjs';
23
+
24
+ /**
25
+ * For each configured (already-loaded) integration, compute its issues using
26
+ * the shared validate-integration validators and, if any exist, print exactly
27
+ * ONE line per integration to stderr:
28
+ *
29
+ * Warning: <pkg> has N integration issue(s). Run: astryx validate-integration <pkg>
30
+ *
31
+ * @param {Array<object>} loadedIntegrations the Project's loaded integrations
32
+ * @param {{json?: boolean}} [options]
33
+ * @returns {Promise<void>}
34
+ */
35
+ export async function warnOnIntegrationIssues(loadedIntegrations, {json = false} = {}) {
36
+ try {
37
+ // Cheap guard: only do work when integrations are actually configured.
38
+ if (json) return;
39
+ if (!Array.isArray(loadedIntegrations) || loadedIntegrations.length === 0) {
40
+ return;
41
+ }
42
+ for (const integration of loadedIntegrations) {
43
+ if (!integration || typeof integration !== 'object') continue;
44
+ let issues;
45
+ try {
46
+ issues = await validateLoadedIntegration(integration);
47
+ } catch {
48
+ // Best-effort: a validator throwing must not break the host command.
49
+ continue;
50
+ }
51
+ if (!Array.isArray(issues) || issues.length === 0) continue;
52
+ const pkg = integration.name ?? integration.__spec ?? '(integration)';
53
+ // Stderr only — keeps stdout (and any --json envelope) clean.
54
+ console.error(
55
+ `Warning: ${pkg} has ${issues.length} integration issue(s). ` +
56
+ `Run: astryx validate-integration ${pkg}`,
57
+ );
58
+ }
59
+ } catch {
60
+ // Never throw, never change the exit code.
61
+ }
62
+ }
@@ -0,0 +1,102 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Hermetic tests for the compact integration-issue nudge.
5
+ *
6
+ * Builds loaded-integration-shaped objects (as produced by
7
+ * lib/integrations.mjs) with absolute contribution roots under the repo root
8
+ * (process.cwd()) so any node_modules-style path stays within Vite's allowed
9
+ * fs roots. We never load configs from /tmp. The helper is exercised directly;
10
+ * stderr is captured to assert exactly one warning line per broken integration
11
+ * and zero output in --json mode / for clean integrations.
12
+ */
13
+
14
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import {warnOnIntegrationIssues} from './integration-warnings.mjs';
18
+
19
+ let tmpDir;
20
+ let errSpy;
21
+ let errLines;
22
+
23
+ beforeEach(() => {
24
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-warn-it-'));
25
+ errLines = [];
26
+ errSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
27
+ errLines.push(args.join(' '));
28
+ });
29
+ });
30
+
31
+ afterEach(() => {
32
+ errSpy.mockRestore();
33
+ fs.rmSync(tmpDir, {recursive: true, force: true});
34
+ });
35
+
36
+ /**
37
+ * Build a loaded-integration-shaped object. Roots are absolute (mirrors
38
+ * loadIntegrations' resolveRoot).
39
+ */
40
+ function loaded({name = '@acme/widgets', components, templates, codemods} = {}) {
41
+ return {
42
+ name,
43
+ version: '1.0.0',
44
+ components: components == null ? undefined : path.join(tmpDir, components),
45
+ templates: templates == null ? undefined : path.join(tmpDir, templates),
46
+ codemods: codemods == null ? undefined : path.join(tmpDir, codemods),
47
+ __spec: name,
48
+ __packageDir: tmpDir,
49
+ };
50
+ }
51
+
52
+ describe('warnOnIntegrationIssues', () => {
53
+ it('emits exactly one stderr line for an integration with issues', async () => {
54
+ // Declared components root that does not exist on disk → missing_root.
55
+ const integration = loaded({components: 'does-not-exist'});
56
+ await warnOnIntegrationIssues([integration], {json: false});
57
+
58
+ expect(errLines).toHaveLength(1);
59
+ expect(errLines[0]).toBe(
60
+ 'Warning: @acme/widgets has 1 integration issue(s). ' +
61
+ 'Run: astryx validate-integration @acme/widgets',
62
+ );
63
+ });
64
+
65
+ it('emits nothing in --json mode (keeps stdout/JSON clean)', async () => {
66
+ const integration = loaded({components: 'does-not-exist'});
67
+ await warnOnIntegrationIssues([integration], {json: true});
68
+ expect(errLines).toHaveLength(0);
69
+ });
70
+
71
+ it('emits nothing for an integration with no issues', async () => {
72
+ // Existing components root with no broken contributions → no issues.
73
+ const componentsRoot = path.join(tmpDir, 'components');
74
+ fs.mkdirSync(componentsRoot, {recursive: true});
75
+ const integration = loaded({components: 'components'});
76
+ await warnOnIntegrationIssues([integration], {json: false});
77
+ expect(errLines).toHaveLength(0);
78
+ });
79
+
80
+ it('emits nothing when there are no configured integrations', async () => {
81
+ await warnOnIntegrationIssues([], {json: false});
82
+ await warnOnIntegrationIssues(undefined, {json: false});
83
+ expect(errLines).toHaveLength(0);
84
+ });
85
+
86
+ it('emits one line per broken integration', async () => {
87
+ const a = loaded({name: '@acme/a', components: 'missing-a'});
88
+ const b = loaded({name: '@acme/b', templates: 'missing-b'});
89
+ await warnOnIntegrationIssues([a, b], {json: false});
90
+ expect(errLines).toHaveLength(2);
91
+ expect(errLines[0]).toContain('@acme/a');
92
+ expect(errLines[1]).toContain('@acme/b');
93
+ });
94
+
95
+ it('never throws and never emits when a validator misbehaves', async () => {
96
+ // A non-object entry must be skipped silently.
97
+ await expect(
98
+ warnOnIntegrationIssues([null, 'nope', 42], {json: false}),
99
+ ).resolves.toBeUndefined();
100
+ expect(errLines).toHaveLength(0);
101
+ });
102
+ });
@@ -0,0 +1,132 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Integration manifest loading for Astryx config.
5
+ *
6
+ * Integrations are PACKAGE NAMES listed in astryx.config.{ts,mjs,js}. Each
7
+ * package declares a single conventional root manifest sibling to its
8
+ * package.json — astryx.integration.{ts,mjs,js} — which contributes
9
+ * components/templates/codemods roots and an optional issuesUrl. Identity
10
+ * (name, version) comes from the package's package.json, not the manifest.
11
+ */
12
+
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import {AstryxIntegrationSchema} from './config-schema.mjs';
16
+ import {loadModuleWithSchema, findPresentFiles} from './module-loader.mjs';
17
+
18
+ /** Conventional manifest basenames, in load-precedence order. */
19
+ export const MANIFEST_BASENAMES = [
20
+ 'astryx.integration.ts',
21
+ 'astryx.integration.mjs',
22
+ 'astryx.integration.js',
23
+ ];
24
+
25
+ /**
26
+ * Return the conventional root manifest paths present in `dir`, in
27
+ * load-precedence order. Unlike {@link resolveManifestPath} this never throws —
28
+ * callers (e.g. validate-integration) decide how to treat zero / multiple.
29
+ * @param {string} dir
30
+ * @returns {string[]} absolute manifest paths
31
+ */
32
+ export function findManifestPaths(dir) {
33
+ return findPresentFiles(dir, MANIFEST_BASENAMES);
34
+ }
35
+
36
+ /**
37
+ * Load and validate a manifest module's default export against the integration
38
+ * schema. Default export only — `.ts` is loaded via jiti; `.mjs`/`.js` via
39
+ * dynamic import. Throws if the default export is missing or invalid. Exposed
40
+ * for validate-integration.
41
+ * @param {string} file absolute manifest path
42
+ * @param {string} [label] used in error messages
43
+ * @returns {Promise<import('../types/integration').AstryxIntegration>}
44
+ */
45
+ export async function loadManifestObject(file, label = 'integration manifest') {
46
+ return loadModuleWithSchema(file, AstryxIntegrationSchema, {label});
47
+ }
48
+
49
+ /**
50
+ * Resolve the directory of an installed integration package from the consumer's
51
+ * node_modules.
52
+ * @param {string} packageName
53
+ * @param {string} [cwd]
54
+ */
55
+ export function resolvePackageDir(packageName, cwd = process.cwd()) {
56
+ return path.resolve(cwd, 'node_modules', ...packageName.split('/'));
57
+ }
58
+
59
+ /**
60
+ * Find the single conventional root manifest sibling of the package's
61
+ * package.json. Multiple present is a hard error; missing is a hard error.
62
+ * @param {string} packageDir
63
+ * @param {string} spec
64
+ * @returns {string} absolute manifest path
65
+ */
66
+ function resolveManifestPath(packageDir, spec) {
67
+ const present = findPresentFiles(packageDir, MANIFEST_BASENAMES);
68
+ if (present.length === 0) {
69
+ throw new Error(
70
+ `Integration package "${spec}" has no conventional root manifest. Add one of: ${MANIFEST_BASENAMES.join(', ')} next to its package.json.`,
71
+ );
72
+ }
73
+ if (present.length > 1) {
74
+ throw new Error(
75
+ `Integration package "${spec}" has multiple root manifests (${present
76
+ .map(file => path.basename(file))
77
+ .join(', ')}). Keep exactly one.`,
78
+ );
79
+ }
80
+ return present[0];
81
+ }
82
+
83
+ /**
84
+ * Load configured integrations.
85
+ *
86
+ * @param {string[]} [specs] package names
87
+ * @param {{cwd?: string}} [options]
88
+ */
89
+ export async function loadIntegrations(specs = [], {cwd = process.cwd()} = {}) {
90
+ const integrations = [];
91
+ const seen = new Set();
92
+
93
+ for (const spec of specs) {
94
+ if (!spec || seen.has(spec)) continue;
95
+ seen.add(spec);
96
+
97
+ const packageDir = resolvePackageDir(spec, cwd);
98
+ const pkgPath = path.join(packageDir, 'package.json');
99
+ let pkg;
100
+ try {
101
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
102
+ } catch {
103
+ throw new Error(
104
+ `Could not find installed integration package "${spec}" at ${pkgPath}. Install it first.`,
105
+ );
106
+ }
107
+
108
+ const manifestFile = resolveManifestPath(packageDir, spec);
109
+ const manifest = await loadModuleWithSchema(
110
+ manifestFile,
111
+ AstryxIntegrationSchema,
112
+ {label: `Integration ${spec}`},
113
+ );
114
+
115
+ const resolveRoot = value =>
116
+ value == null ? undefined : path.resolve(packageDir, value);
117
+
118
+ integrations.push({
119
+ name: pkg.name ?? spec,
120
+ version: pkg.version,
121
+ components: resolveRoot(manifest.components),
122
+ templates: resolveRoot(manifest.templates),
123
+ codemods: resolveRoot(manifest.codemods),
124
+ issuesUrl: manifest.issuesUrl,
125
+ __spec: spec,
126
+ __packageDir: packageDir,
127
+ __manifestFile: manifestFile,
128
+ });
129
+ }
130
+
131
+ return integrations;
132
+ }