@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,260 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Codemod: migrate astryx.config `layout.components` to
5
+ * `experimental.xle.components`.
6
+ *
7
+ * The published 0.1.2 CLI read XLE app-component registration from
8
+ * `astryx.config.*` under `layout.components`. The next release relocates this
9
+ * to `experimental.xle.components`, validated by a strict schema that REJECTS
10
+ * unknown keys — so any consumer that still has `layout.components` would
11
+ * hard-error on upgrade. This codemod performs the straight relocation.
12
+ *
13
+ * The semantics are IDENTICAL between the old and new shapes; this is purely a
14
+ * MOVE plus a string→object normalization. No meaning is changed:
15
+ *
16
+ * OLD `layout.components` entry forms (from the 0.1.2 shim):
17
+ * - string `'X'` → a NAMED import; the KEY is both the local name and the
18
+ * export name.
19
+ * - object `{ from, description?, default? }` → `default: true` means a
20
+ * default import; otherwise a named import (key = export name).
21
+ *
22
+ * NEW `experimental.xle.components` entry shape (already shipped on the
23
+ * branch; unchanged here): `{ from, description?, default? }`, with the same
24
+ * semantics.
25
+ *
26
+ * So the migration is:
27
+ * - string `'X'` → `{ from: 'X' }`
28
+ * - object `{ from, description?, default? }` → carried over UNCHANGED
29
+ * (from/description/default preserved, key order preserved).
30
+ *
31
+ * The transform recognizes the config default export as EITHER a bare object
32
+ * literal (`export default { ... }`) or a `createConfig({ ... })` call wrapping
33
+ * an object literal (`export default createConfig({ ... })`). Anything else is
34
+ * not statically analyzable and the transform THROWS with a clear
35
+ * migrate-manually message (the unified config-codemod contract has no
36
+ * structured-error return — `runConfigCodemod` surfaces a thrown `err.message`
37
+ * as `{file, codemod, error}`).
38
+ */
39
+
40
+ export const meta = {
41
+ title: 'Migrate astryx.config layout.components to experimental.xle.components',
42
+ description:
43
+ 'Relocates XLE app-component registration from the legacy ' +
44
+ '`layout.components` config key to `experimental.xle.components`. String ' +
45
+ 'entries (`Name: "@/path"`) are normalized to `{ from: "@/path" }` ' +
46
+ '(named import, key = export name); object entries are carried over ' +
47
+ 'unchanged (from/description/default preserved). Bails with a clear ' +
48
+ 'migrate-manually error when the config cannot be statically analyzed or ' +
49
+ 'when merging would be unsafe.',
50
+ codemodType: 'config',
51
+ };
52
+
53
+ const MANUAL =
54
+ 'migrate layout.components to experimental.xle.components manually';
55
+
56
+ /**
57
+ * @param {{source: string, path: string}} file
58
+ * @param {{jscodeshift: Function}} api
59
+ * @returns {string|null}
60
+ */
61
+ export default function transformer(file, api) {
62
+ const j = api.jscodeshift;
63
+ const root = j(file.source);
64
+
65
+ const defaultExports = root.find(j.ExportDefaultDeclaration);
66
+ if (defaultExports.size() === 0) {
67
+ throw new Error(
68
+ `could not statically analyze astryx config (no default export); ${MANUAL}`,
69
+ );
70
+ }
71
+
72
+ const declaration = defaultExports.paths()[0].node.declaration;
73
+
74
+ // Resolve the config object literal from either form:
75
+ // export default { ... }
76
+ // export default createConfig({ ... })
77
+ const configObject = resolveConfigObject(j, declaration);
78
+ if (!configObject) {
79
+ throw new Error(
80
+ `could not statically analyze astryx config; ${MANUAL}`,
81
+ );
82
+ }
83
+
84
+ // Locate the top-level `layout` property and its `components` value.
85
+ const layoutProp = findObjectProperty(j, configObject, 'layout');
86
+ if (!layoutProp) {
87
+ return null; // no layout → nothing to migrate
88
+ }
89
+ if (!j.ObjectExpression.check(layoutProp.value)) {
90
+ throw new Error(
91
+ `astryx config \`layout\` is not an object literal; ${MANUAL}`,
92
+ );
93
+ }
94
+
95
+ const componentsProp = findObjectProperty(j, layoutProp.value, 'components');
96
+ if (!componentsProp) {
97
+ return null; // no layout.components → nothing to migrate
98
+ }
99
+ if (!j.ObjectExpression.check(componentsProp.value)) {
100
+ throw new Error(
101
+ `astryx config \`layout.components\` is not an object literal; ${MANUAL}`,
102
+ );
103
+ }
104
+
105
+ // `components` was the only documented `layout` key. Anything else is
106
+ // unexpected and we cannot safely guess intent.
107
+ const otherLayoutKeys = layoutProp.value.properties.filter(
108
+ p => p !== componentsProp,
109
+ );
110
+ if (otherLayoutKeys.length > 0) {
111
+ throw new Error(
112
+ `astryx config \`layout\` has keys other than \`components\`; ${MANUAL}`,
113
+ );
114
+ }
115
+
116
+ // Build the migrated `components` object literal entry by entry.
117
+ const migratedComponents = buildMigratedComponents(j, componentsProp.value);
118
+
119
+ // Placement into `experimental.xle.components`.
120
+ const experimentalProp = findObjectProperty(j, configObject, 'experimental');
121
+ if (experimentalProp) {
122
+ if (!j.ObjectExpression.check(experimentalProp.value)) {
123
+ throw new Error(
124
+ `astryx config \`experimental\` is not an object literal; ${MANUAL}`,
125
+ );
126
+ }
127
+ const xleProp = findObjectProperty(j, experimentalProp.value, 'xle');
128
+ if (xleProp) {
129
+ // Can't safely merge into a pre-existing experimental.xle.
130
+ throw new Error(
131
+ `astryx config already defines \`experimental.xle\`; ${MANUAL}`,
132
+ );
133
+ }
134
+ experimentalProp.value.properties.push(
135
+ j.objectProperty(j.identifier('xle'), buildXleObject(j, migratedComponents)),
136
+ );
137
+ } else {
138
+ configObject.properties.push(
139
+ j.objectProperty(
140
+ j.identifier('experimental'),
141
+ j.objectExpression([
142
+ j.objectProperty(j.identifier('xle'), buildXleObject(j, migratedComponents)),
143
+ ]),
144
+ ),
145
+ );
146
+ }
147
+
148
+ // Remove the migrated data from `layout`. Since `components` was the only
149
+ // key (asserted above), remove the entire `layout` property.
150
+ configObject.properties = configObject.properties.filter(
151
+ p => p !== layoutProp,
152
+ );
153
+
154
+ return root.toSource();
155
+ }
156
+
157
+ /**
158
+ * Resolve the wrapped config object literal from a default-export declaration.
159
+ * Handles `{ ... }` and `createConfig({ ... })`.
160
+ * @returns {object|null} the ObjectExpression node, or null if not analyzable
161
+ */
162
+ function resolveConfigObject(j, declaration) {
163
+ if (!declaration) return null;
164
+ if (j.ObjectExpression.check(declaration)) {
165
+ return declaration;
166
+ }
167
+ if (
168
+ j.CallExpression.check(declaration) &&
169
+ declaration.arguments.length >= 1 &&
170
+ j.ObjectExpression.check(declaration.arguments[0])
171
+ ) {
172
+ return declaration.arguments[0];
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Find a (non-computed, non-spread) property on an object literal by key name.
179
+ * Supports Identifier and string-literal keys.
180
+ * @returns {object|undefined} the property node
181
+ */
182
+ function findObjectProperty(j, objectExpression, name) {
183
+ return objectExpression.properties.find(prop => {
184
+ if (
185
+ !j.ObjectProperty.check(prop) &&
186
+ !j.Property.check(prop)
187
+ ) {
188
+ return false;
189
+ }
190
+ if (prop.computed) return false;
191
+ const key = prop.key;
192
+ if (j.Identifier.check(key)) return key.name === name;
193
+ if (j.StringLiteral.check(key)) return key.value === name;
194
+ if (j.Literal.check(key)) return key.value === name;
195
+ return false;
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Build the migrated `components` ObjectExpression from the old one.
201
+ * @returns {object} ObjectExpression
202
+ */
203
+ function buildMigratedComponents(j, oldComponents) {
204
+ const newProps = oldComponents.properties.map(prop => {
205
+ if (
206
+ (!j.ObjectProperty.check(prop) && !j.Property.check(prop)) ||
207
+ prop.computed
208
+ ) {
209
+ throw new Error(
210
+ `astryx config \`layout.components\` has a non-literal entry; ${MANUAL}`,
211
+ );
212
+ }
213
+
214
+ const value = prop.value;
215
+
216
+ // string `'X'` → `{ from: 'X' }`
217
+ if (j.StringLiteral.check(value) || (j.Literal.check(value) && typeof value.value === 'string')) {
218
+ return j.objectProperty(
219
+ cloneKey(j, prop.key),
220
+ j.objectExpression([
221
+ j.objectProperty(j.identifier('from'), cloneStringLiteral(j, value)),
222
+ ]),
223
+ );
224
+ }
225
+
226
+ // object `{ from, description?, default? }` → carried over UNCHANGED
227
+ if (j.ObjectExpression.check(value)) {
228
+ return j.objectProperty(cloneKey(j, prop.key), value);
229
+ }
230
+
231
+ // anything else (identifier, call, ternary, etc.) → cannot safely migrate
232
+ throw new Error(
233
+ `astryx config \`layout.components\` entry is neither a string nor an ` +
234
+ `object literal and cannot be safely migrated; ${MANUAL}`,
235
+ );
236
+ });
237
+
238
+ return j.objectExpression(newProps);
239
+ }
240
+
241
+ /** Build `{ components: <obj> }` for `experimental.xle`. */
242
+ function buildXleObject(j, migratedComponents) {
243
+ return j.objectExpression([
244
+ j.objectProperty(j.identifier('components'), migratedComponents),
245
+ ]);
246
+ }
247
+
248
+ /** Clone a property key node so we don't reuse a node across the tree. */
249
+ function cloneKey(j, key) {
250
+ if (j.Identifier.check(key)) return j.identifier(key.name);
251
+ if (j.StringLiteral.check(key)) return j.stringLiteral(key.value);
252
+ if (j.Literal.check(key)) return j.literal(key.value);
253
+ return key;
254
+ }
255
+
256
+ /** Build a fresh string literal preserving the original raw quoting if possible. */
257
+ function cloneStringLiteral(j, value) {
258
+ if (j.StringLiteral.check(value)) return j.stringLiteral(value.value);
259
+ return j.literal(value.value);
260
+ }
@@ -77,7 +77,7 @@ afterEach(() => {
77
77
  });
78
78
 
79
79
  describe('theme build install-instructions import path', () => {
80
- it('emits ./<name> (no double slash) when out dir equals cwd', () => {
80
+ it('emits bare ./<name> specifiers (no double slash) when out dir equals cwd', () => {
81
81
  // Source theme lives directly in the project root, so the output dir
82
82
  // is the cwd and the relative dir is an empty string.
83
83
  const project = path.join(tmpDir, 'project');
@@ -97,7 +97,7 @@ describe('theme build install-instructions import path', () => {
97
97
  expect(result.stdout).toContain('href="./cwd-theme.css"');
98
98
  });
99
99
 
100
- it('emits ./<sub>/<name> when out dir is a subdirectory of cwd', () => {
100
+ it('keeps the subdir in the import path for a non-src subdirectory build', () => {
101
101
  const project = path.join(tmpDir, 'project');
102
102
  const themeFile = writeTheme(path.join(project, 'themes'), 'sub-theme');
103
103
 
@@ -112,4 +112,24 @@ describe('theme build install-instructions import path', () => {
112
112
  expect(result.stdout).toContain("import './themes/sub-theme.css'");
113
113
  expect(result.stdout).toContain('href="./themes/sub-theme.css"');
114
114
  });
115
+
116
+ it('strips a leading src/ from the import path (relative to a file in src/)', () => {
117
+ const project = path.join(tmpDir, 'project');
118
+ const themeFile = writeTheme(
119
+ path.join(project, 'src', 'themes', 'ocean'),
120
+ 'ocean',
121
+ );
122
+
123
+ const result = runCli(
124
+ ['theme', 'build', path.relative(project, themeFile)],
125
+ project,
126
+ );
127
+
128
+ expect(result.code).toBe(0);
129
+ expect(result.stdout).not.toContain('.//');
130
+ // The leading src/ is dropped, but themes/ocean/ is kept (people need it).
131
+ expect(result.stdout).toContain("from './themes/ocean/ocean'");
132
+ expect(result.stdout).toContain("import './themes/ocean/ocean.css'");
133
+ expect(result.stdout).not.toContain('./src/');
134
+ });
115
135
  });
@@ -17,10 +17,15 @@ import * as path from 'node:path';
17
17
  import {pathToFileURL, fileURLToPath} from 'node:url';
18
18
  import {createJiti} from 'jiti';
19
19
  import {getRunPrefix} from '../utils/package-manager.mjs';
20
- import {sanitizeName, PathSafetyError} from '../utils/path-safety.mjs';
20
+ import {
21
+ sanitizeName,
22
+ PathSafetyError,
23
+ isNonInteractive,
24
+ } from '../utils/path-safety.mjs';
21
25
  import {jsonOut, humanLog} from '../lib/json.mjs';
22
26
  import {cliError} from '../lib/cli-error.mjs';
23
27
  import {ERROR_CODES} from '../lib/error-codes.mjs';
28
+ import {themeAdd, listThemes} from '../api/theme-add.mjs';
24
29
 
25
30
  // Import shared theme processing from core. `astryx theme build` MUST produce the
26
31
  // exact same CSS as the `<Theme>` runtime, so it has exactly one generation
@@ -75,6 +80,18 @@ function toIdentifier(name) {
75
80
  return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
76
81
  }
77
82
 
83
+ /**
84
+ * Import specifier for install/scaffold instructions. Drops a leading `src/`
85
+ * from the cwd-relative dir (most consumers import from a file under src/) but
86
+ * keeps the rest of the path (e.g. `themes/gothic`). Callers note the path is
87
+ * relative to the consumer's file.
88
+ */
89
+ function importSpecifier(relDir, base) {
90
+ const normalized = relDir === '.' ? '' : relDir;
91
+ const withinSrc = normalized.replace(/^src\/?/, '').replace(/\/+$/, '');
92
+ return withinSrc ? `./${withinSrc}/${base}` : `./${base}`;
93
+ }
94
+
78
95
  /**
79
96
  * Convert a kebab-case component name to PascalCase.
80
97
  * e.g. 'button' → 'Button', 'progress-bar' → 'ProgressBar', 'avatar-status-dot' → 'AvatarStatusDot'
@@ -443,7 +460,7 @@ const KNOWN_COMPONENTS = {
443
460
  breadcrumbs: ['variant'],
444
461
  button: ['variant', 'size'],
445
462
  calendar: [],
446
- card: [],
463
+ card: ['variant'],
447
464
  center: [],
448
465
  checkboxinput: [],
449
466
  collapsible: [],
@@ -737,10 +754,12 @@ export function registerTheme(program) {
737
754
  const sourceRelative = path.relative(process.cwd(), filePath);
738
755
  const buildCommand = `astryx theme build ${sourceRelative}${options.out ? ' --out ' + path.relative(process.cwd(), path.resolve(process.cwd(), options.out)) : ''}`;
739
756
 
740
- // Determine output path
757
+ // Derive the default CSS name from the theme name so .css/.js/.d.ts
758
+ // share one scheme; an explicit --out still wins.
759
+ const baseName = themeDef.name;
741
760
  const outPath = options.out
742
761
  ? path.resolve(process.cwd(), options.out)
743
- : filePath.replace(/\.(ts|tsx|js|jsx|mjs)$/, '.css');
762
+ : path.join(path.dirname(filePath), `${baseName}.css`);
744
763
 
745
764
  const displayTheme = resolvedTheme || themeDef;
746
765
  const tokenCount = displayTheme.tokens ? Object.keys(displayTheme.tokens).length : 0;
@@ -753,7 +772,6 @@ export function registerTheme(program) {
753
772
  // was left as orphaned half-built output. Stage-then-commit avoids
754
773
  // that.
755
774
  const outDir = path.dirname(outPath);
756
- const baseName = themeDef.name;
757
775
  const jsPath = path.join(outDir, `${baseName}.js`);
758
776
  const dtsPath = path.join(outDir, `${baseName}.d.ts`);
759
777
 
@@ -834,18 +852,16 @@ export function registerTheme(program) {
834
852
  });
835
853
  }
836
854
 
837
- // Print install instructions
838
- const relDir = path.relative(process.cwd(), outDir);
839
- // When the output dir is the cwd, relDir is empty — avoid emitting a
840
- // double-slash import path like './/<name>'. Build a './<relDir>/'
841
- // prefix that collapses to './' when relDir is empty.
842
- const importPrefix = relDir ? `./${relDir}/` : './';
855
+ const relOutDir = path.relative(process.cwd(), outDir) || '.';
856
+ const cssBase = path.basename(outPath, '.css');
857
+ const jsImport = importSpecifier(relOutDir, baseName);
858
+ const cssImport = importSpecifier(relOutDir, cssBase) + '.css';
843
859
  const exportName = `${toIdentifier(baseName)}Theme`;
844
860
  humanLog(`
845
- Install in your app:
861
+ Install in your app (paths are relative to a file in src/ — adjust if yours lives elsewhere):
846
862
 
847
- import { ${exportName} } from '${importPrefix}${baseName}';
848
- import '${importPrefix}${baseName}.css';
863
+ import { ${exportName} } from '${jsImport}';
864
+ import '${cssImport}';
849
865
 
850
866
  <Theme theme={${exportName}}>
851
867
  <App />
@@ -853,9 +869,9 @@ Install in your app:
853
869
 
854
870
  Or with a <link> tag:
855
871
 
856
- import { ${exportName} } from '${importPrefix}${baseName}';
872
+ import { ${exportName} } from '${jsImport}';
857
873
 
858
- <link rel="stylesheet" href="${importPrefix}${baseName}.css" />
874
+ <link rel="stylesheet" href="${cssImport}" />
859
875
  <Theme theme={${exportName}}>
860
876
  <App />
861
877
  </Theme>
@@ -871,4 +887,157 @@ Or with a <link> tag:
871
887
  humanLog('');
872
888
  }
873
889
  });
890
+
891
+ theme
892
+ .command('list')
893
+ .description('List themes available to add')
894
+ .action(async () => {
895
+ const json = program.opts().json || false;
896
+ let result;
897
+ try {
898
+ result = await themeAdd(undefined, {list: true, cwd: process.cwd()});
899
+ } catch (e) {
900
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
901
+ return;
902
+ }
903
+
904
+ if (json) return jsonOut(result.type, result.data);
905
+
906
+ const themes = result.data;
907
+ if (themes.length === 0) {
908
+ humanLog('\nNo themes are bundled with this CLI build.\n');
909
+ return;
910
+ }
911
+ humanLog('\nThemes:\n');
912
+ for (const t of themes) {
913
+ const tag = t.maintained ? ' (maintained)' : '';
914
+ humanLog(` ${t.slug}${tag}`);
915
+ if (t.description) humanLog(` ${t.description}`);
916
+ }
917
+ humanLog('\nUsage:');
918
+ humanLog(' astryx theme add <slug> [target-path] Scaffold a theme file you own\n');
919
+ });
920
+
921
+ theme
922
+ .command('add [slug] [path]')
923
+ .description('Scaffold a theme into your project as editable source')
924
+ .option('-f, --overwrite', 'Overwrite existing files without prompting')
925
+ .option('--list', 'List available themes')
926
+ .action(async (slug, targetPath, options) => {
927
+ const json = program.opts().json || false;
928
+
929
+ // Only prompt with a real TTY on stdin — a piped/redirected stdin would
930
+ // make clack hang. Non-interactive callers fall through to the API's
931
+ // ERR_FILE_EXISTS guard.
932
+ const interactive =
933
+ !json && !isNonInteractive({json}) && Boolean(process.stdin.isTTY);
934
+ if (slug && !options.list && !options.overwrite && interactive) {
935
+ const collision = await detectThemeCollision(slug, targetPath);
936
+ if (collision) {
937
+ const rel = path.relative(process.cwd(), collision) || collision;
938
+ const p = await import('@clack/prompts');
939
+ const confirmed = await p.confirm({
940
+ message: `Overwrite existing file ${rel}?`,
941
+ initialValue: false,
942
+ });
943
+ if (p.isCancel(confirmed)) {
944
+ p.cancel('Cancelled.');
945
+ return;
946
+ }
947
+ if (!confirmed) {
948
+ humanLog('Aborted. Re-run with --overwrite to replace the file.');
949
+ return;
950
+ }
951
+ options.overwrite = true;
952
+ }
953
+ }
954
+
955
+ let result;
956
+ try {
957
+ result = await themeAdd(slug, {
958
+ list: options.list,
959
+ targetPath,
960
+ overwrite: options.overwrite,
961
+ cwd: process.cwd(),
962
+ });
963
+ } catch (e) {
964
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
965
+ return;
966
+ }
967
+
968
+ if (json) return jsonOut(result.type, result.data);
969
+
970
+ if (result.type === 'theme.list') {
971
+ const themes = result.data;
972
+ humanLog('\nThemes:\n');
973
+ for (const t of themes) {
974
+ const tag = t.maintained ? ' (maintained)' : '';
975
+ humanLog(` ${t.slug}${tag}`);
976
+ if (t.description) humanLog(` ${t.description}`);
977
+ }
978
+ humanLog('\nUsage:');
979
+ humanLog(' astryx theme add <slug> [target-path] Scaffold a theme file you own\n');
980
+ return;
981
+ }
982
+
983
+ // theme.add — print where files landed + how to use the theme.
984
+ const {displayName, outputDir, entry, exportName, files} = result.data;
985
+ humanLog(`\n✓ Added ${displayName} theme to ${outputDir}/`);
986
+ for (const f of files) {
987
+ humanLog(` ${outputDir}/${f}`);
988
+ }
989
+ const entryModule = importSpecifier(
990
+ outputDir,
991
+ entry.replace(/\.tsx?$/, ''),
992
+ );
993
+ humanLog(`
994
+ Use it in your app (import path is relative to a file in src/ — adjust if yours lives elsewhere):
995
+
996
+ import { ${exportName} } from '${entryModule}';
997
+
998
+ <Theme theme={${exportName}}>
999
+ <App />
1000
+ </Theme>
1001
+
1002
+ This is your copy of the ${displayName} theme — edit ${entry} to make it your own.
1003
+ `);
1004
+ });
1005
+ }
1006
+
1007
+ /**
1008
+ * First existing file that scaffolding <slug> into <targetPath> would clobber,
1009
+ * or null. Used to prompt before invoking the API; the API re-validates and
1010
+ * owns any authoritative error.
1011
+ *
1012
+ * @param {string} slug
1013
+ * @param {string} [targetPath]
1014
+ * @returns {Promise<string|null>}
1015
+ */
1016
+ async function detectThemeCollision(slug, targetPath) {
1017
+ let themes;
1018
+ try {
1019
+ themes = listThemes();
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ const match = themes.find(t => t.slug.toLowerCase() === slug.toLowerCase());
1024
+ if (!match) return null;
1025
+
1026
+ const rawTarget = targetPath || path.join('src', 'themes', match.slug);
1027
+ let resolvedDir;
1028
+ try {
1029
+ // Fail soft (null) on traversal; the API surfaces the real error.
1030
+ const {assertWithin} = await import('../utils/path-safety.mjs');
1031
+ resolvedDir = assertWithin(rawTarget, process.cwd(), {
1032
+ label: 'theme target path',
1033
+ });
1034
+ } catch {
1035
+ return null;
1036
+ }
1037
+
1038
+ for (const name of match.files) {
1039
+ const dest = path.join(resolvedDir, name);
1040
+ if (fs.existsSync(dest)) return dest;
1041
+ }
1042
+ return null;
874
1043
  }
@@ -24,6 +24,8 @@ import {cliError} from '../../lib/cli-error.mjs';
24
24
  import {ERROR_CODES} from '../../lib/error-codes.mjs';
25
25
  import {component as componentApi} from '../../api/component.mjs';
26
26
  import {findRelatedBlocks} from '../../api/template.mjs';
27
+ import {Project} from '../../lib/project.mjs';
28
+ import {warnOnIntegrationIssues} from '../../lib/integration-warnings.mjs';
27
29
 
28
30
  export function registerComponent(program) {
29
31
  program
@@ -55,6 +57,16 @@ export function registerComponent(program) {
55
57
  return;
56
58
  }
57
59
 
60
+ // Non-blocking nudge: if any configured integration has validation
61
+ // issues, print one compact line to stderr pointing at
62
+ // validate-integration. Best-effort; suppressed in --json mode.
63
+ try {
64
+ const project = await Project.load(process.cwd());
65
+ await warnOnIntegrationIssues(project.loadedIntegrations, {json});
66
+ } catch {
67
+ // Never let the nudge break the command.
68
+ }
69
+
58
70
  let result;
59
71
  try {
60
72
  result = await componentApi(name, {
@@ -82,27 +94,49 @@ export function registerComponent(program) {
82
94
 
83
95
  switch (result.type) {
84
96
  case 'component.list': {
85
- // --detail brief (default for list views) names with import paths.
97
+ // --detail brief (default for list views). The API now returns
98
+ // package-qualified entries ({name, package}); the human view omits
99
+ // the core package label for readability but ALWAYS shows the package
100
+ // for integration components (and whenever names collide).
101
+ const CORE_PKG = '@astryxdesign/core';
102
+ // Names that appear under more than one package across the whole
103
+ // listing — these must always be package-qualified to disambiguate.
104
+ const nameCounts = new Map();
105
+ for (const items of Object.values(result.data)) {
106
+ for (const item of items) {
107
+ const set = nameCounts.get(item.name) ?? new Set();
108
+ set.add(item.package);
109
+ nameCounts.set(item.name, set);
110
+ }
111
+ }
112
+ const isCollision = n => (nameCounts.get(n)?.size ?? 0) > 1;
113
+ const pkgSuffix = item => {
114
+ if (item.package !== CORE_PKG) return ` [${item.package}]`;
115
+ if (isCollision(item.name)) return ` [${item.package}]`;
116
+ return '';
117
+ };
118
+
86
119
  if (options.category) {
87
120
  const [cat, comps] = Object.entries(result.data)[0];
88
121
  humanLog(`\n${cat}:`);
89
- for (const comp of comps) {
90
- const importPath = resolveImportPath(coreDir, comp);
91
- humanLog(` ${comp} ← ${importPath}`);
122
+ for (const item of comps) {
123
+ const importPath = resolveImportPath(coreDir, item.name);
124
+ humanLog(` ${item.name} ← ${importPath}${pkgSuffix(item)}`);
92
125
  }
93
126
  humanLog('');
94
127
  } else {
95
128
  humanLog('');
96
129
  for (const [key, comps] of Object.entries(result.data)) {
97
- const isUngrouped = comps.length === 1 && comps[0] === key;
130
+ const isUngrouped = comps.length === 1 && comps[0]?.name === key;
98
131
  if (isUngrouped) {
99
- const importPath = resolveImportPath(coreDir, key);
100
- humanLog(`${key} ← ${importPath}`);
132
+ const item = comps[0];
133
+ const importPath = resolveImportPath(coreDir, item.name);
134
+ humanLog(`${item.name} ← ${importPath}${pkgSuffix(item)}`);
101
135
  } else {
102
136
  humanLog(`${key} (group)`);
103
- for (const comp of comps) {
104
- const importPath = resolveImportPath(coreDir, comp);
105
- humanLog(` ${comp} ← ${importPath}`);
137
+ for (const item of comps) {
138
+ const importPath = resolveImportPath(coreDir, item.name);
139
+ humanLog(` ${item.name} ← ${importPath}${pkgSuffix(item)}`);
106
140
  }
107
141
  }
108
142
  }