@astryxdesign/cli 0.1.2 → 0.1.3

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 (238) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +49 -55
  3. package/docs/layout.doc.dense.mjs +90 -0
  4. package/docs/layout.doc.mjs +160 -0
  5. package/docs/principles.doc.dense.mjs +2 -2
  6. package/docs/principles.doc.mjs +4 -0
  7. package/docs/principles.doc.zh.mjs +2 -2
  8. package/docs/theme.doc.mjs +2 -2
  9. package/package.json +13 -1
  10. package/src/api/blog.mjs +192 -0
  11. package/src/api/blog.test.mjs +144 -0
  12. package/src/api/component.mjs +253 -12
  13. package/src/api/discover.mjs +14 -7
  14. package/src/api/doctor.mjs +10 -25
  15. package/src/api/index.mjs +1 -0
  16. package/src/api/layout.mjs +11 -10
  17. package/src/api/layout.test.mjs +4 -1
  18. package/src/api/template-integration.test.mjs +225 -0
  19. package/src/api/template.mjs +242 -84
  20. package/src/api/validate-integration.mjs +370 -0
  21. package/src/api/validate-integration.test.mjs +222 -0
  22. package/src/codemod.mjs +93 -0
  23. package/src/codemod.test.mjs +134 -0
  24. package/src/codemods/__tests__/registry.test.mjs +1 -0
  25. package/src/codemods/__tests__/runner.test.mjs +103 -0
  26. package/src/codemods/integration-discovery.mjs +168 -0
  27. package/src/codemods/integration-discovery.test.mjs +234 -0
  28. package/src/codemods/integration-runner.mjs +109 -0
  29. package/src/codemods/registry.mjs +1 -0
  30. package/src/codemods/run-codemod.mjs +207 -0
  31. package/src/codemods/runner.mjs +73 -96
  32. package/src/codemods/transforms/v0.0.15/index.mjs +0 -13
  33. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-css-surfaces.test.mjs +67 -0
  34. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
  35. package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +104 -0
  36. package/src/codemods/transforms/{v0.0.15 → v0.1.0}/drop-xds-prefix-imports.mjs +15 -4
  37. package/src/codemods/transforms/v0.1.0/index.mjs +43 -6
  38. package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
  39. package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -0
  40. package/src/codemods/transforms/v0.1.3/__tests__/migrate-layout-components-to-experimental.test.mjs +360 -0
  41. package/src/codemods/transforms/v0.1.3/index.mjs +19 -0
  42. package/src/codemods/transforms/v0.1.3/migrate-layout-components-to-experimental.mjs +260 -0
  43. package/src/commands/agent-docs.mjs +4 -1
  44. package/src/commands/blog.mjs +60 -0
  45. package/src/commands/build-theme.import-path.test.mjs +2 -12
  46. package/src/commands/build-theme.mjs +117 -0
  47. package/src/commands/build-theme.prose.test.mjs +5 -15
  48. package/src/commands/build-theme.watch.test.mjs +149 -0
  49. package/src/commands/component/index.mjs +44 -10
  50. package/src/commands/component-ownership.test.mjs +227 -0
  51. package/src/commands/discover.mjs +5 -16
  52. package/src/commands/doctor.test.mjs +3 -3
  53. package/src/commands/ensure-core-built.mjs +120 -0
  54. package/src/commands/json-contract.test.mjs +0 -32
  55. package/src/commands/swizzle.mjs +224 -235
  56. package/src/commands/swizzle.path-safety.test.mjs +3 -3
  57. package/src/commands/swizzle.routing.test.mjs +279 -0
  58. package/src/commands/template.mjs +33 -37
  59. package/src/commands/upgrade.config-ordering.test.mjs +273 -0
  60. package/src/commands/upgrade.integration-policy.test.mjs +204 -0
  61. package/src/commands/upgrade.mjs +349 -170
  62. package/src/commands/validate-integration.mjs +110 -0
  63. package/src/commands/validate-integration.test.mjs +124 -0
  64. package/src/config.mjs +7 -20
  65. package/src/config.test.mjs +89 -13
  66. package/src/index.mjs +8 -3
  67. package/src/integration.mjs +19 -0
  68. package/src/lib/component-discovery.mjs +177 -0
  69. package/src/lib/config-cache.mjs +91 -0
  70. package/src/lib/config-cache.test.mjs +83 -0
  71. package/src/lib/config-schema.mjs +41 -55
  72. package/src/lib/error-codes.mjs +15 -12
  73. package/src/lib/integration-warnings.mjs +62 -0
  74. package/src/lib/integration-warnings.test.mjs +102 -0
  75. package/src/lib/integrations.mjs +92 -115
  76. package/src/lib/integrations.test.mjs +88 -107
  77. package/src/lib/manifest.mjs +5 -2
  78. package/src/lib/module-loader.mjs +80 -0
  79. package/src/lib/module-loader.test.mjs +106 -0
  80. package/src/lib/project.mjs +502 -0
  81. package/src/lib/project.test.mjs +308 -0
  82. package/src/lib/site.mjs +20 -0
  83. package/src/template.mjs +73 -0
  84. package/src/template.test.mjs +127 -0
  85. package/src/types/api.d.ts +2 -6
  86. package/src/types/base.d.ts +3 -9
  87. package/src/types/codemod.d.ts +81 -0
  88. package/src/types/component.d.ts +27 -2
  89. package/src/types/config.d.ts +56 -85
  90. package/src/types/error-codes.d.ts +5 -3
  91. package/src/types/index.d.ts +0 -1
  92. package/src/types/integration.d.ts +29 -0
  93. package/src/types/swizzle.d.ts +9 -2
  94. package/src/types/template-api.d.ts +54 -0
  95. package/src/types/template.d.ts +10 -7
  96. package/src/types/upgrade.d.ts +29 -0
  97. package/src/types/validate-integration.d.ts +24 -0
  98. package/src/utils/github.mjs +0 -237
  99. package/src/utils/interactive.mjs +2 -2
  100. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
  101. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
  102. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +14 -0
  103. package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +34 -0
  104. package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +14 -0
  105. package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +21 -0
  106. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
  107. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
  108. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
  109. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
  110. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
  111. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
  112. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
  113. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
  114. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
  115. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
  116. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
  117. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
  118. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
  119. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
  120. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
  121. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
  122. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
  123. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
  124. package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
  125. package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
  126. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.doc.mjs +14 -0
  127. package/templates/blocks/components/DateRangeInput/DateRangeInputWithPresets.tsx +46 -0
  128. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.doc.mjs +14 -0
  129. package/templates/blocks/components/DateRangeInput/DateRangeInputWithValidation.tsx +52 -0
  130. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.doc.mjs +14 -0
  131. package/templates/blocks/components/DateTimeInput/DateTimeInputWithValidation.tsx +43 -0
  132. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
  133. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
  134. package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -6
  135. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
  136. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
  137. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -5
  138. package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
  139. package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
  140. package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
  141. package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
  142. package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
  143. package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
  144. package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
  145. package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
  146. package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
  147. package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
  148. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +14 -0
  149. package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +57 -0
  150. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.doc.mjs +1 -1
  151. package/templates/blocks/components/HoverCard/HoverCardInteractiveContent.tsx +9 -4
  152. package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
  153. package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
  154. package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
  155. package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
  156. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
  157. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
  158. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
  159. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
  160. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
  161. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
  162. package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +14 -0
  163. package/templates/blocks/components/Lightbox/LightboxGallery.tsx +53 -0
  164. package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +4 -3
  165. package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +14 -0
  166. package/templates/blocks/components/Lightbox/LightboxVideo.tsx +27 -0
  167. package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +14 -0
  168. package/templates/blocks/components/Lightbox/LightboxZoom.tsx +33 -0
  169. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +1 -1
  170. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
  171. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
  172. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
  173. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
  174. package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -6
  175. package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
  176. package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
  177. package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
  178. package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
  179. package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
  180. package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
  181. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
  182. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
  183. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
  184. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
  185. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
  186. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
  187. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
  188. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
  189. package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
  190. package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
  191. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
  192. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
  193. package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
  194. package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
  195. package/templates/blocks/components/Tab/TabWithSelectedIcon.doc.mjs +13 -0
  196. package/templates/blocks/components/Tab/TabWithSelectedIcon.tsx +39 -0
  197. package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
  198. package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
  199. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +1 -1
  200. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
  201. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
  202. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
  203. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
  204. package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
  205. package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
  206. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
  207. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
  208. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
  209. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
  210. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
  211. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
  212. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
  213. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
  214. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
  215. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
  216. package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
  217. package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
  218. package/templates/pages/kanban-board/page.tsx +729 -0
  219. package/templates/pages/kanban-board/template.doc.mjs +12 -0
  220. package/templates/pages/shell-nav/page.tsx +321 -0
  221. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  222. package/templates/pages/shell-side-nav/page.tsx +241 -0
  223. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  224. package/templates/pages/shell-top-nav/page.tsx +224 -0
  225. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  226. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-config-surfaces.test.mjs +0 -116
  227. package/src/codemods/transforms/v0.1.0/migrate-xds-config-surfaces.mjs +0 -230
  228. package/src/commands/gap-report.mjs +0 -464
  229. package/src/commands/gap-report.test.mjs +0 -168
  230. package/src/commands/swizzle-gap-safety.test.mjs +0 -273
  231. package/src/lib/config.mjs +0 -113
  232. package/src/lib/config.test.mjs +0 -91
  233. package/src/types/gap-report.d.ts +0 -29
  234. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
  235. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
  236. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
  237. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
  238. /package/src/codemods/transforms/{v0.0.15 → v0.1.0}/__tests__/drop-xds-prefix-imports.test.mjs +0 -0
@@ -0,0 +1,60 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file blog command (hidden) — read the Astryx blog from the published feed.
5
+ *
6
+ * Hidden on purpose: it's not part of the documented command surface and does
7
+ * not appear in --help or the manifest. It reads the blog the same way any
8
+ * feed reader would — over the public RSS feed — and prints a post's plaintext
9
+ * (.txt) variant. Nothing about the blog's structure has to change for this to
10
+ * work; the CLI is just a consumer of the feed.
11
+ *
12
+ * astryx blog List posts from the feed
13
+ * astryx blog <slug> Print a post as plaintext
14
+ */
15
+
16
+ import {getRunPrefix} from '../utils/package-manager.mjs';
17
+ import {humanLog} from '../lib/json.mjs';
18
+ import {cliError} from '../lib/cli-error.mjs';
19
+ import {blog as blogApi} from '../api/blog.mjs';
20
+
21
+ function formatList({feedUrl, posts}, run) {
22
+ const lines = [`\nAstryx blog · feed: ${feedUrl}\n`];
23
+ if (posts.length === 0) {
24
+ lines.push('No posts found in the feed.');
25
+ return lines.join('\n');
26
+ }
27
+ for (const p of posts) {
28
+ lines.push(` ${p.slug}`);
29
+ lines.push(` ${p.title}`);
30
+ if (p.type) lines.push(` ${p.type}`);
31
+ if (p.textUrl) lines.push(` ${p.textUrl}`);
32
+ lines.push('');
33
+ }
34
+ lines.push(`Read one: ${run} astryx blog <slug>`);
35
+ return lines.join('\n');
36
+ }
37
+
38
+ export function registerBlog(program) {
39
+ program
40
+ .command('blog [slug]', {hidden: true})
41
+ .description('Read the Astryx blog from the published feed')
42
+ .action(async slug => {
43
+ const run = getRunPrefix();
44
+ let result;
45
+ try {
46
+ result = await blogApi(slug);
47
+ } catch (e) {
48
+ cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
49
+ return;
50
+ }
51
+
52
+ if (result.type === 'blog.list') {
53
+ humanLog(formatList(result.data, run));
54
+ } else {
55
+ // blog.detail — print the feed URL, then the plaintext body.
56
+ humanLog(`Feed: ${result.data.feedUrl}\n`);
57
+ humanLog(result.data.text);
58
+ }
59
+ });
60
+ }
@@ -18,14 +18,10 @@ import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
19
  import * as os from 'node:os';
20
20
  import {fileURLToPath} from 'node:url';
21
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
21
22
 
22
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
24
  const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
24
- const REPO_ROOT = path.resolve(__dirname, '../../../..');
25
- const CORE_THEME_ENTRY = path.join(
26
- REPO_ROOT,
27
- 'packages/core/dist/theme/index.js',
28
- );
29
25
 
30
26
  function runCli(args, cwd) {
31
27
  try {
@@ -59,13 +55,7 @@ function writeTheme(dir, name) {
59
55
  // in-CLI fallback generator). Build core once if it isn't already present so
60
56
  // the suite works in any CI job, regardless of job ordering.
61
57
  beforeAll(() => {
62
- if (!fs.existsSync(CORE_THEME_ENTRY)) {
63
- execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
64
- cwd: REPO_ROOT,
65
- stdio: 'pipe',
66
- timeout: 180_000,
67
- });
68
- }
58
+ ensureCoreBuilt();
69
59
  }, 200_000);
70
60
 
71
61
  let tmpDir;
@@ -15,6 +15,7 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import {pathToFileURL, fileURLToPath} from 'node:url';
18
+ import {spawn} from 'node:child_process';
18
19
  import {createJiti} from 'jiti';
19
20
  import {getRunPrefix} from '../utils/package-manager.mjs';
20
21
  import {
@@ -597,6 +598,104 @@ function validatePrivateVars(themeDef) {
597
598
  return errors;
598
599
  }
599
600
 
601
+ /**
602
+ * Path to this CLI's real entry (bin/astryx.mjs), resolved from this module's
603
+ * location (src/commands/build-theme.mjs → ../../bin/astryx.mjs). Used to
604
+ * re-invoke `theme build` as a child process in watch mode.
605
+ */
606
+ function resolveCliBin() {
607
+ const commandsDir = path.dirname(fileURLToPath(import.meta.url));
608
+ return path.resolve(commandsDir, '../../bin/astryx.mjs');
609
+ }
610
+
611
+ /**
612
+ * Run a single `theme build` as a child process, reusing the exact
613
+ * single-build code path (and its error handling) rather than duplicating it.
614
+ * Resolves with the child's exit code; never rejects.
615
+ *
616
+ * @param {string} file - The theme file argument, as the user passed it.
617
+ * @param {object} options - Parsed command options (only `out` is forwarded).
618
+ * @returns {Promise<number>}
619
+ */
620
+ function runThemeBuildOnceChild(file, options) {
621
+ const cliBin = resolveCliBin();
622
+ const args = [cliBin, 'theme', 'build', file];
623
+ if (options.out) args.push('--out', options.out);
624
+ return new Promise(resolve => {
625
+ const child = spawn(process.execPath, args, {
626
+ stdio: 'inherit',
627
+ env: process.env,
628
+ });
629
+ child.on('close', code => resolve(code ?? 0));
630
+ child.on('error', () => resolve(1));
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Watch a theme file and rebuild on change. Runs an initial build, then
636
+ * rebuilds (debounced) whenever the file changes, until interrupted with
637
+ * Ctrl-C. Each rebuild runs in a child process so a build error (which the
638
+ * single-build path reports via a hard exit) is contained and the watcher
639
+ * keeps running.
640
+ *
641
+ * @param {string} file - The theme file argument, as the user passed it.
642
+ * @param {string} filePath - Absolute path to the theme file.
643
+ * @param {object} options - Parsed command options.
644
+ * @returns {Promise<void>} Resolves when the watcher is stopped (Ctrl-C).
645
+ */
646
+ async function runThemeBuildWatch(file, filePath, options) {
647
+ const rel = path.relative(process.cwd(), filePath);
648
+
649
+ // Initial build.
650
+ await runThemeBuildOnceChild(file, options);
651
+
652
+ humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`);
653
+
654
+ let building = false;
655
+ let queued = false;
656
+ let debounce = null;
657
+
658
+ const rebuild = async () => {
659
+ if (building) {
660
+ // Coalesce changes that land mid-build into a single follow-up run.
661
+ queued = true;
662
+ return;
663
+ }
664
+ building = true;
665
+ humanLog(`\n♻️ Change detected — rebuilding ${rel}...`);
666
+ await runThemeBuildOnceChild(file, options);
667
+ building = false;
668
+ humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`);
669
+ if (queued) {
670
+ queued = false;
671
+ rebuild();
672
+ }
673
+ };
674
+
675
+ // Some editors replace the file (rename) rather than writing in place, which
676
+ // can drop the watch. Watch the containing directory and filter to our file
677
+ // so edits survive atomic-save/rename.
678
+ const watchDir = path.dirname(filePath);
679
+ const baseName = path.basename(filePath);
680
+ const watcher = fs.watch(watchDir, (_eventType, changed) => {
681
+ if (changed && changed !== baseName) return;
682
+ clearTimeout(debounce);
683
+ // Debounce: editors often emit several events per save.
684
+ debounce = setTimeout(rebuild, 100);
685
+ });
686
+
687
+ await new Promise(resolve => {
688
+ const stop = () => {
689
+ clearTimeout(debounce);
690
+ watcher.close();
691
+ humanLog('\nStopped watching.');
692
+ resolve();
693
+ };
694
+ process.once('SIGINT', stop);
695
+ process.once('SIGTERM', stop);
696
+ });
697
+ }
698
+
600
699
  export function registerTheme(program) {
601
700
  const theme = program
602
701
  .command('theme')
@@ -621,6 +720,10 @@ export function registerTheme(program) {
621
720
  .command('build <file>')
622
721
  .description('Compile a defineTheme file to CSS + JS')
623
722
  .option('-o, --out <path>', 'Output CSS file path')
723
+ .option(
724
+ '-w, --watch',
725
+ 'Rebuild automatically when the theme file changes (Ctrl-C to stop)',
726
+ )
624
727
  .action(async (file, options) => {
625
728
  const filePath = path.resolve(process.cwd(), file);
626
729
  const json = program.opts().json || false;
@@ -630,6 +733,20 @@ export function registerTheme(program) {
630
733
  return;
631
734
  }
632
735
 
736
+ // Watch mode: run an initial build, then rebuild on every change to the
737
+ // theme file. Watch is a human-interactive, long-running mode — it is not
738
+ // supported in --json (machine) mode, which expects a single envelope.
739
+ if (options.watch) {
740
+ if (json) {
741
+ cliError('--watch is not supported with --json', {
742
+ code: ERROR_CODES.ERR_THEME_INVALID,
743
+ });
744
+ return;
745
+ }
746
+ await runThemeBuildWatch(file, filePath, options);
747
+ return;
748
+ }
749
+
633
750
  if (!json) humanLog(`\nBuilding theme from ${path.relative(process.cwd(), filePath)}...`);
634
751
 
635
752
  // Extract theme definition
@@ -18,9 +18,9 @@
18
18
  * - paragraphs use the body font, not the heading font.
19
19
  *
20
20
  * Building `astryx theme build` requires a compiled @astryxdesign/core (there is no in-CLI
21
- * fallback generator), so this suite builds core once in beforeAll mirroring
22
- * scripts/build-css.test.mjs to stay self-sufficient regardless of CI job
23
- * ordering.
21
+ * fallback generator), so this suite builds core once in beforeAll via the
22
+ * shared ensureCoreBuilt() helper which serializes concurrent Vitest workers
23
+ * behind a lock — to stay self-sufficient regardless of CI job ordering.
24
24
  */
25
25
 
26
26
  import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
@@ -29,14 +29,10 @@ import * as fs from 'node:fs';
29
29
  import * as path from 'node:path';
30
30
  import * as os from 'node:os';
31
31
  import {fileURLToPath} from 'node:url';
32
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
32
33
 
33
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
35
  const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
35
- const REPO_ROOT = path.resolve(__dirname, '../../../..');
36
- const CORE_THEME_ENTRY = path.join(
37
- REPO_ROOT,
38
- 'packages/core/dist/theme/index.js',
39
- );
40
36
 
41
37
  function runCli(args, cwd) {
42
38
  try {
@@ -71,13 +67,7 @@ function writeTheme(dir, name) {
71
67
  // `astryx theme build` imports the compiled @astryxdesign/core/theme entry. Build core
72
68
  // once if it isn't already present so the suite works in any CI job.
73
69
  beforeAll(() => {
74
- if (!fs.existsSync(CORE_THEME_ENTRY)) {
75
- execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
76
- cwd: REPO_ROOT,
77
- stdio: 'pipe',
78
- timeout: 180_000,
79
- });
80
- }
70
+ ensureCoreBuilt();
81
71
  }, 200_000);
82
72
 
83
73
  let tmpDir;
@@ -0,0 +1,149 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Tests for `astryx theme build --watch` (#3375).
5
+ *
6
+ * Watch mode runs an initial build, then rebuilds whenever the theme file
7
+ * changes, until interrupted. Each rebuild runs in a child process so a build
8
+ * error (reported by the single-build path via a hard exit) is contained and
9
+ * the watcher keeps running.
10
+ *
11
+ * Building `astryx theme build` requires a compiled @astryxdesign/core, so this
12
+ * suite builds core once in beforeAll via the shared ensureCoreBuilt() helper —
13
+ * which serializes concurrent Vitest workers behind a lock — to stay
14
+ * self-sufficient regardless of CI job ordering.
15
+ */
16
+
17
+ import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
18
+ import {execFileSync, spawn} from 'node:child_process';
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as os from 'node:os';
22
+ import {fileURLToPath} from 'node:url';
23
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
27
+
28
+ function runCli(args, cwd) {
29
+ try {
30
+ const out = execFileSync('node', [CLI_BIN, ...args], {
31
+ cwd,
32
+ encoding: 'utf-8',
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ env: {...process.env, FORCE_COLOR: '0'},
35
+ });
36
+ return {code: 0, stdout: out, stderr: ''};
37
+ } catch (e) {
38
+ return {
39
+ code: e.status ?? 1,
40
+ stdout: e.stdout?.toString() ?? '',
41
+ stderr: e.stderr?.toString() ?? '',
42
+ };
43
+ }
44
+ }
45
+
46
+ /** Poll until `predicate()` is true or the timeout elapses. */
47
+ async function waitFor(predicate, {timeout = 8000, interval = 50} = {}) {
48
+ const start = Date.now();
49
+ for (;;) {
50
+ if (predicate()) return true;
51
+ if (Date.now() - start > timeout) return false;
52
+ await new Promise(r => setTimeout(r, interval));
53
+ }
54
+ }
55
+
56
+ beforeAll(() => {
57
+ ensureCoreBuilt();
58
+ }, 200_000);
59
+
60
+ let tmpDir;
61
+ beforeEach(() => {
62
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-watch-'));
63
+ });
64
+ afterEach(() => {
65
+ fs.rmSync(tmpDir, {recursive: true, force: true});
66
+ });
67
+
68
+ describe('theme build --watch', () => {
69
+ it('advertises the flag in --help', () => {
70
+ const result = runCli(['theme', 'build', '--help'], process.cwd());
71
+ const out = result.stdout + result.stderr;
72
+ expect(out).toMatch(/--watch/);
73
+ });
74
+
75
+ it('rejects --watch together with --json (single-envelope contract)', () => {
76
+ const themeFile = path.join(tmpDir, 'wt.mjs');
77
+ fs.writeFileSync(
78
+ themeFile,
79
+ `export default { name: 'wt', tokens: { '--color-bg': '#fff' } };\n`,
80
+ );
81
+ const result = runCli(
82
+ ['--json', 'theme', 'build', path.relative(tmpDir, themeFile), '--watch'],
83
+ tmpDir,
84
+ );
85
+ expect(result.code).not.toBe(0);
86
+ expect(result.stdout + result.stderr).toMatch(/watch/i);
87
+ });
88
+
89
+ it('builds initially, rebuilds on change, and stops cleanly on SIGINT', async () => {
90
+ const themeFile = path.join(tmpDir, 'wt.mjs');
91
+ const cssFile = path.join(tmpDir, 'wt.css');
92
+ fs.writeFileSync(
93
+ themeFile,
94
+ `export default { name: 'wt', tokens: { '--color-bg': '#ffffff' } };\n`,
95
+ );
96
+
97
+ const child = spawn(
98
+ process.execPath,
99
+ [CLI_BIN, 'theme', 'build', 'wt.mjs', '--watch'],
100
+ {cwd: tmpDir, env: {...process.env, FORCE_COLOR: '0'}},
101
+ );
102
+ let stdout = '';
103
+ child.stdout.on('data', d => (stdout += d.toString()));
104
+ child.stderr.on('data', d => (stdout += d.toString()));
105
+
106
+ try {
107
+ // Initial build produces the CSS.
108
+ const built = await waitFor(() => fs.existsSync(cssFile));
109
+ expect(built).toBe(true);
110
+ const firstCss = fs.readFileSync(cssFile, 'utf-8');
111
+ expect(firstCss).toMatch(/#ffffff/);
112
+
113
+ // Wait until the watcher is actually watching before editing, so the
114
+ // change isn't missed.
115
+ await waitFor(() => /Watching/i.test(stdout));
116
+
117
+ // Change the theme — the token value changes so the CSS must change.
118
+ fs.writeFileSync(
119
+ themeFile,
120
+ `export default { name: 'wt', tokens: { '--color-bg': '#010203' } };\n`,
121
+ );
122
+
123
+ // The rebuilt CSS should reflect the new value.
124
+ const rebuilt = await waitFor(() => {
125
+ try {
126
+ return fs.readFileSync(cssFile, 'utf-8').includes('#010203');
127
+ } catch {
128
+ return false;
129
+ }
130
+ });
131
+ expect(rebuilt).toBe(true);
132
+ expect(stdout).toMatch(/rebuild/i);
133
+ } finally {
134
+ // SIGINT must stop the watcher and exit cleanly.
135
+ child.kill('SIGINT');
136
+ }
137
+
138
+ const exited = await new Promise(resolve => {
139
+ let done = false;
140
+ child.on('exit', () => {
141
+ done = true;
142
+ resolve(true);
143
+ });
144
+ setTimeout(() => resolve(done), 4000);
145
+ });
146
+ expect(exited).toBe(true);
147
+ expect(stdout).toMatch(/Stopped watching/);
148
+ }, 30_000);
149
+ });
@@ -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
  }