@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
@@ -15,29 +15,44 @@
15
15
  * 3. Refresh agent docs (AGENTS.md / CLAUDE.md) if present
16
16
  *
17
17
  * Options:
18
- * --from <version> Previous version before the dependency upgrade
19
- * --apply Write changes to disk (default: dry-run)
20
- * --force Run codemods even when from >= installed version
21
- * --codemod <name> Run a specific transform only
22
- * --integration <spec> Load an explicit integration package or file
23
- * --path <dir> Source directory (default: ./src)
24
- * --install-deps Auto-install jscodeshift without prompting (for CI/LLM)
18
+ * --from <version> Previous version before the dependency upgrade
19
+ * --apply Write changes to disk (default: dry-run)
20
+ * --force Run codemods even when from >= installed version
21
+ * --codemod <name> Run a specific transform only
22
+ * --skip-codemod <name…> Exclude named codemods (variadic). Use this to
23
+ * re-run past a codemod that failed at execution time.
24
+ * --integration <spec> Load an explicit integration package or file
25
+ * --path <dir> Source directory (default: ./src)
26
+ * --install-deps Auto-install jscodeshift without prompting (for CI/LLM)
27
+ *
28
+ * Integration error policy:
29
+ * - A DISCOVERY/definition error for an integration (bad manifest/export,
30
+ * duplicate ids, missing root) SKIPS that integration's codemods and warns
31
+ * (via the integration-issue nudge) — it does NOT hard-fail the upgrade.
32
+ * - An EXECUTION-time failure (a transform THROWS while rewriting files)
33
+ * ABORTS the upgrade (nonzero exit) so a partial write never proceeds.
25
34
  */
26
35
 
27
36
  import * as fs from 'node:fs';
28
37
  import * as path from 'node:path';
29
- import {pathToFileURL} from 'node:url';
30
38
  import {execFile} from 'node:child_process';
31
39
  import {promisify} from 'node:util';
32
40
  import * as p from '@clack/prompts';
33
41
  import {ensureJscodeshift} from '../codemods/ensure-jscodeshift.mjs';
34
42
  import {getTransformsBetween, latestVersion} from '../codemods/registry.mjs';
35
43
  import {runCodemods} from '../codemods/runner.mjs';
44
+ import {
45
+ discoverIntegrationCodemods,
46
+ selectIntegrationCodemods,
47
+ } from '../codemods/integration-discovery.mjs';
48
+ import {runIntegrationCodemods} from '../codemods/integration-runner.mjs';
36
49
  import {installAgentDocs, discoverAgentDocs} from './agent-docs.mjs';
37
50
  import {getRunPrefix} from '../utils/package-manager.mjs';
38
- import {isValidSemver, semverGte, semverGt} from '../utils/semver.mjs';
51
+ import {isValidSemver, semverGte} from '../utils/semver.mjs';
39
52
  import {jsonOut, jsonError} from '../lib/json.mjs';
40
- import {loadConfig} from '../lib/config.mjs';
53
+ import {Project} from '../lib/project.mjs';
54
+ import {loadIntegrations} from '../lib/integrations.mjs';
55
+ import {warnOnIntegrationIssues} from '../lib/integration-warnings.mjs';
41
56
  import {ERROR_CODES} from '../lib/error-codes.mjs';
42
57
 
43
58
  const execFileAsync = promisify(execFile);
@@ -64,159 +79,59 @@ function detectInstalledTargetVersion() {
64
79
  return null;
65
80
  }
66
81
 
67
- function isPathSpec(spec) {
68
- return (
69
- spec.startsWith('.') ||
70
- spec.startsWith('/') ||
71
- spec.endsWith('.mjs') ||
72
- spec.endsWith('.js')
73
- );
74
- }
75
-
76
- function resolvePackageDir(packageName) {
77
- const parts = packageName.split('/');
78
- return path.resolve(process.cwd(), 'node_modules', ...parts);
82
+ function uniqueFiles(files) {
83
+ return [...new Set((files ?? []).filter(Boolean))];
79
84
  }
80
85
 
81
- function resolveIntegrationFile(spec) {
82
- if (isPathSpec(spec)) {
83
- return path.resolve(process.cwd(), spec);
84
- }
85
-
86
- const packageDir = resolvePackageDir(spec);
87
- const pkgPath = path.join(packageDir, 'package.json');
88
- let pkg;
89
- try {
90
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
91
- } catch {
92
- throw new Error(
93
- `Could not find installed integration package "${spec}" at ${pkgPath}. Install it first or pass a direct integration file path.`,
94
- );
95
- }
96
-
97
- const manifestPath = pkg.astryx?.integration ?? pkg.xds?.integration;
98
- if (!manifestPath) {
99
- throw new Error(
100
- `Package "${spec}" does not declare astryx.integration (or legacy xds.integration) in package.json.`,
101
- );
102
- }
103
- return path.resolve(packageDir, manifestPath);
104
- }
86
+ /**
87
+ * Run the app config's post-codemod hooks (config.hooks.postCodemod).
88
+ *
89
+ * Each hook's `buildCommand({packageDir, files})` returns a command to run
90
+ * (or a nullish value to skip). In dry-run mode we only PREVIEW — buildCommand
91
+ * is called (so a throw still fails the run) but the command is never executed.
92
+ * In apply mode the commands run in order via execFile; a nonzero exit (or a
93
+ * buildCommand throw) fails the upgrade.
94
+ *
95
+ * @param {import('../types/config').PostCodemodHook[]} hooks
96
+ * @param {{packageDir: string, files: string[], apply: boolean}} context
97
+ * @param {boolean} silent
98
+ */
99
+ async function runPostCodemodHooks(hooks, context, silent) {
100
+ if (!hooks || hooks.length === 0) return;
105
101
 
106
- async function loadIntegrations(specs) {
107
- const integrations = [];
108
- for (const spec of specs) {
109
- const file = resolveIntegrationFile(spec);
110
- const mod = await import(pathToFileURL(file).href);
111
- const integration = mod.default ?? mod.integration ?? mod;
112
- if (!integration || typeof integration !== 'object') {
113
- throw new Error(`Integration ${spec} did not export an object.`);
114
- }
115
- const integrationDir = path.dirname(file);
116
- if (Array.isArray(integration.codemods)) {
117
- for (const codemod of integration.codemods) {
118
- if (typeof codemod.transform === 'string') {
119
- const transformPath = path.resolve(integrationDir, codemod.transform);
120
- const transformMod = await import(pathToFileURL(transformPath).href);
121
- codemod.transform =
122
- transformMod.default ?? transformMod.transform ?? transformMod;
123
- }
124
- }
125
- }
126
- integrations.push({
127
- ...integration,
128
- __file: file,
129
- __dir: integrationDir,
130
- __spec: spec,
131
- });
132
- }
133
- return integrations;
134
- }
102
+ const log = silent ? {info() {}, warn() {}, success() {}, error() {}} : p.log;
103
+ const {packageDir, files, apply} = context;
135
104
 
136
- function normalizeIntegrationTransforms(integration, from, to) {
137
- const transforms = [];
138
- for (const entry of integration.codemods ?? []) {
139
- const entryFrom = entry.from ?? '0.0.0';
140
- const entryTo = entry.to ?? to;
141
- if (semverGte(from, entryTo) || semverGt(entryFrom, to)) continue;
142
- if (!entry.name)
143
- throw new Error(
144
- `Integration ${integration.name ?? integration.__spec} has a codemod without a name.`,
145
- );
146
- if (!entry.transform)
105
+ for (let i = 0; i < hooks.length; i++) {
106
+ const hook = hooks[i];
107
+ const label = hook.name ?? `postCodemod[${i}]`;
108
+ if (typeof hook.buildCommand !== 'function') {
147
109
  throw new Error(
148
- `Integration codemod ${entry.name} is missing transform.`,
110
+ `Post-codemod hook ${label} is missing a buildCommand function.`,
149
111
  );
150
- const directTransform =
151
- typeof entry.transform === 'function' ? entry.transform : null;
152
- if (!directTransform)
153
- throw new Error(
154
- `Integration codemod ${entry.name} did not resolve to a function.`,
155
- );
156
- transforms.push({
157
- name: entry.name,
158
- meta: {
159
- title:
160
- entry.title ??
161
- `${integration.name ?? integration.__spec}: ${entry.name}`,
162
- description: entry.description ?? '',
163
- pr: entry.pr,
164
- fileExtensions: entry.fileExtensions,
165
- },
166
- optional: !!entry.optional,
167
- transform: directTransform,
168
- });
169
- }
170
- return transforms.length ? [{version: to, transforms}] : [];
171
- }
172
-
173
- function uniqueFiles(files) {
174
- return [...new Set((files ?? []).filter(Boolean))];
175
- }
112
+ }
176
113
 
177
- async function runPostCodemodHooks(integrations, context, silent) {
178
- const hooks = integrations.flatMap(integration =>
179
- (integration.postCodemod ?? []).map(hook => ({integration, hook})),
180
- );
181
- if (hooks.length === 0) return;
114
+ const cmd = await hook.buildCommand({packageDir, files});
115
+ if (!cmd) {
116
+ log.info(`Post-codemod hook ${label} produced no command; skipping.`);
117
+ continue;
118
+ }
182
119
 
183
- const log = silent ? {info() {}, warn() {}, success() {}, error() {}} : p.log;
120
+ if (!apply) {
121
+ const preview = [cmd.command, ...(cmd.args ?? [])].join(' ');
122
+ log.info(`Post-codemod hook ${label} (dry run): ${preview}`);
123
+ continue;
124
+ }
184
125
 
185
- const run = async (command, args, options = {}) => {
186
- await execFileAsync(command, args, {
187
- cwd: options.cwd ?? context.packageDir,
188
- timeout: options.timeoutMs ?? 300_000,
126
+ await execFileAsync(cmd.command, cmd.args ?? [], {
127
+ cwd: cmd.options?.cwd ?? packageDir,
128
+ timeout: cmd.options?.timeout ?? 300_000,
189
129
  stdio: 'pipe',
190
130
  encoding: 'utf-8',
191
- env: {...process.env, ...(options.env ?? {})},
131
+ ...cmd.options,
132
+ env: {...process.env, ...(cmd.options?.env ?? {})},
192
133
  });
193
- };
194
-
195
- const ctx = {...context, run};
196
- for (const {integration, hook} of hooks) {
197
- const label = `${integration.name ?? integration.__spec}:${hook.name ?? 'postCodemod'}`;
198
- try {
199
- if (typeof hook.run === 'function') {
200
- await hook.run(ctx);
201
- } else if (typeof hook.command === 'function') {
202
- const cmd = await hook.command(ctx);
203
- if (cmd) {
204
- await run(cmd.command, cmd.args ?? [], {
205
- cwd: cmd.cwd,
206
- timeoutMs: cmd.timeoutMs,
207
- env: cmd.env,
208
- });
209
- }
210
- } else {
211
- log.warn(
212
- `Integration hook ${label} has no run() or command() function; skipping.`,
213
- );
214
- continue;
215
- }
216
- log.success(`Post-codemod hook ${label} completed.`);
217
- } catch (err) {
218
- log.warn(`Post-codemod hook ${label} failed: ${err.message}`);
219
- }
134
+ log.success(`Post-codemod hook ${label} completed.`);
220
135
  }
221
136
  }
222
137
 
@@ -238,6 +153,10 @@ export function registerUpgrade(program) {
238
153
  false,
239
154
  )
240
155
  .option('--codemod <name>', 'Run a specific transform only')
156
+ .option(
157
+ '--skip-codemod <name...>',
158
+ 'Exclude named codemods (repeatable). Re-run past a failed codemod by skipping it.',
159
+ )
241
160
  .option(
242
161
  '--integration <package-or-file>',
243
162
  'Explicit integration package name or integration file path (repeatable)',
@@ -335,15 +254,170 @@ export function registerUpgrade(program) {
335
254
  );
336
255
  }
337
256
 
257
+ // ───────────────────────────────────────────────────────────────────
258
+ // PIPELINE ORDERING
259
+ //
260
+ // CORE codemods run BEFORE the consumer's config is loaded. `Project.load`
261
+ // STRICT-validates astryx.config.* and THROWS on unknown keys — but a core
262
+ // CONFIG codemod (e.g. v0.1.3 migrate-layout-components, signalled by
263
+ // `meta.codemodType === 'config'`) is precisely what repairs an otherwise
264
+ // -invalid config. Loading first created a chicken-and-egg: the config was
265
+ // rejected before the codemod that would fix it ever ran. Core codemods
266
+ // read files directly (config codemods via runConfigCodemod read
267
+ // astryx.config.*; code codemods scan --path), so they do NOT need the
268
+ // loaded config. We run them here, then load config, then sequence the
269
+ // integration codemods (which DO require a valid loaded config).
270
+ // ───────────────────────────────────────────────────────────────────
271
+
272
+ if (!options.force && semverGte(currentVersion, targetVersion)) {
273
+ if (json) {
274
+ return jsonOut('upgrade.status', {
275
+ status: 'up_to_date',
276
+ from: currentVersion,
277
+ to: targetVersion,
278
+ });
279
+ }
280
+ p.log.success('Already up to date — no codemods to run.');
281
+ p.log.info('Use --force to run codemods anyway.');
282
+ p.outro('Done');
283
+ return;
284
+ }
285
+
286
+ // Resolve CORE transforms from the registry. These do not need the loaded
287
+ // config. Integration codemods are discovered later, AFTER the config
288
+ // loads successfully (they require a valid config to resolve).
289
+ const versionManifests = [
290
+ ...(await getTransformsBetween(currentVersion, targetVersion)),
291
+ ];
292
+
293
+ // Does the selected core set include >=1 CONFIG codemod? A config codemod
294
+ // is the established convention `meta.codemodType === 'config'` (see
295
+ // `toUnifiedEntry` in runner.mjs). This drives the graceful dry-run catch
296
+ // around `Project.load` below: a fixable config error is only "expected"
297
+ // when a pending core config codemod would repair it.
298
+ const coreConfigCodemodNames = [];
299
+ for (const {transforms} of versionManifests) {
300
+ for (const t of transforms) {
301
+ if (options.codemod && t.name !== options.codemod) continue;
302
+ if (t.meta?.codemodType === 'config') {
303
+ coreConfigCodemodNames.push(t.name);
304
+ }
305
+ }
306
+ }
307
+ const hasCoreConfigCodemod = coreConfigCodemodNames.length > 0;
308
+
309
+ // Codemods explicitly excluded via --skip-codemod, matched by the same
310
+ // identifier the run loop uses (core transform `t.name`, integration
311
+ // codemod `c.id`). Lets a user re-run past a codemod that failed at
312
+ // execution time.
313
+ const skipCodemods = new Set(options.skipCodemod ?? []);
314
+
315
+ // Count CORE transforms (optional codemods only count when explicitly
316
+ // requested). Integration counts are added after discovery below.
317
+ let totalTransforms = 0;
318
+ let totalOptional = 0;
319
+ for (const {transforms} of versionManifests) {
320
+ for (const t of transforms) {
321
+ if (options.codemod && t.name !== options.codemod) continue;
322
+ if (skipCodemods.has(t.name)) continue;
323
+ if (t.optional && !options.codemod) {
324
+ totalOptional++;
325
+ } else {
326
+ totalTransforms++;
327
+ }
328
+ }
329
+ }
330
+
331
+ // Ensure jscodeshift is available before running any codemod.
332
+ const ready = await ensureJscodeshift({
333
+ installDeps: options.installDeps,
334
+ silent: json,
335
+ });
336
+ if (!ready) {
337
+ if (json)
338
+ return jsonError(
339
+ 'jscodeshift is required but could not be installed.',
340
+ undefined,
341
+ ERROR_CODES.ERR_DEP_MISSING,
342
+ );
343
+ p.outro('Aborted');
344
+ process.exitCode = 1;
345
+ return;
346
+ }
347
+
348
+ // STEP 3 — Run CORE codemods FIRST (before loading config). In --apply,
349
+ // core config codemods WRITE the repaired config to disk; in dry-run they
350
+ // only PREVIEW. This is what makes the v0.1.3 config codemod reachable on
351
+ // a config that the strict loader would otherwise reject.
352
+ const codemodResult = await runCodemods(versionManifests, {
353
+ apply: options.apply,
354
+ path: options.path,
355
+ codemod: options.codemod,
356
+ skipCodemods,
357
+ silent: json,
358
+ });
359
+
360
+ // STEP 4 — Load the consumer's config (STRICT validation; unchanged). On
361
+ // --apply this now sees the repaired config the core codemod just wrote.
362
+ // We need `hooks.postCodemod` and the configured integration specs from
363
+ // here. Wrap in a graceful dry-run catch (see below).
364
+ // Assigned inside the try below; every catch branch returns, so these
365
+ // are always set before any later read.
338
366
  let integrations;
367
+ let postCodemodHooks;
368
+ let integrationVersionGroups;
339
369
  try {
340
- const config = await loadConfig(process.cwd());
370
+ const project = await Project.load(process.cwd());
371
+ postCodemodHooks = project.config.hooks?.postCodemod ?? [];
341
372
  const integrationSpecs = uniqueFiles([
342
- ...(config.integrations ?? []),
373
+ ...(project.integrations ?? []),
343
374
  ...(options.integration ?? []),
344
375
  ]);
345
376
  integrations = await loadIntegrations(integrationSpecs);
346
377
  } catch (err) {
378
+ // GRACEFUL DRY-RUN CATCH. A config that fails strict validation is the
379
+ // EXPECTED, fixable case ONLY when we are in dry-run AND a pending core
380
+ // config codemod just PREVIEWED a change to the config — i.e. the very
381
+ // codemod that would repair it. (Merely having a config codemod in the
382
+ // range is not enough: it may be a no-op on this config, in which case
383
+ // the validation error is genuine and we must abort. A config codemod
384
+ // that THREW reports zero would-change files, so it also fails this
385
+ // gate and aborts below — preserving the strictness contract.) This is
386
+ // the reason this PR reorders the pipeline.
387
+ const codemodWouldFixConfig =
388
+ hasCoreConfigCodemod && (codemodResult?.totalFilesChanged ?? 0) > 0;
389
+ if (!options.apply && codemodWouldFixConfig) {
390
+ const codemodFlags = coreConfigCodemodNames
391
+ .map(name => `--codemod ${name}`)
392
+ .join(' ');
393
+ const suggestedCommand = `astryx upgrade --from ${currentVersion} ${codemodFlags} --apply`;
394
+ const guidance =
395
+ 'Your astryx.config currently fails strict validation, but a pending ' +
396
+ 'config codemod would repair it. This dry run previewed the fix without ' +
397
+ 'writing. Re-run with --apply to apply it, or run just the config codemod(s) ' +
398
+ 'now:';
399
+ if (json) {
400
+ return jsonOut('upgrade.status', {
401
+ status: 'config_fixable',
402
+ from: currentVersion,
403
+ to: targetVersion,
404
+ configError: err.message,
405
+ configCodemods: coreConfigCodemodNames,
406
+ suggestedCommand,
407
+ message: guidance,
408
+ note: 'Integrations are skipped in this preview; they will be processed on the --apply run.',
409
+ });
410
+ }
411
+ p.log.warn(guidance);
412
+ p.log.info(` ${suggestedCommand}`);
413
+ p.log.info(
414
+ 'Integrations are skipped in this preview; they will be processed on the --apply run.',
415
+ );
416
+ p.outro('Dry run complete');
417
+ return;
418
+ }
419
+ // Genuine config error (apply mode, OR dry-run with no pending core
420
+ // config codemod that would fix it): abort as before.
347
421
  if (json)
348
422
  return jsonError(
349
423
  err.message,
@@ -355,39 +429,71 @@ export function registerUpgrade(program) {
355
429
  process.exitCode = 1;
356
430
  return;
357
431
  }
432
+
358
433
  if (!json && integrations.length > 0) {
359
434
  p.log.info(
360
435
  `Integrations: ${integrations.map(i => i.name ?? i.__spec).join(', ')}`,
361
436
  );
362
437
  }
363
438
 
364
- if (!options.force && semverGte(currentVersion, targetVersion)) {
365
- if (json) {
366
- return jsonOut('upgrade.status', {
367
- status: 'up_to_date',
368
- from: currentVersion,
369
- to: targetVersion,
370
- });
439
+ // Non-blocking nudge: if any configured integration has validation
440
+ // issues, print one compact line to stderr pointing at
441
+ // validate-integration. Best-effort; suppressed in --json mode. This
442
+ // depends on integrations being loaded, so it lives after the successful
443
+ // config load (it is skipped on the graceful dry-run path above, where
444
+ // integrations were never loaded).
445
+ try {
446
+ await warnOnIntegrationIssues(integrations, {json});
447
+ } catch {
448
+ // Never let the nudge break the upgrade.
449
+ }
450
+
451
+ // STEP 5 — Discover + run INTEGRATION codemods (only reached on a
452
+ // successful config load). A broken integration (bad export, invalid
453
+ // schema, duplicate id, missing root) is a DEFINITION error: skip that
454
+ // integration's codemods and warn (via the nudge above), rather than
455
+ // hard-failing the upgrade. An EXECUTION-time failure (a transform
456
+ // throwing) is handled later by the codemod-error gate, which still
457
+ // aborts the upgrade.
458
+ const integrationCodemodsByVersion = new Map();
459
+ for (const integration of integrations) {
460
+ if (!integration?.codemods) continue;
461
+ try {
462
+ const byVersion = await discoverIntegrationCodemods([integration]);
463
+ for (const [version, list] of byVersion) {
464
+ const existing = integrationCodemodsByVersion.get(version);
465
+ if (existing) existing.push(...list);
466
+ else integrationCodemodsByVersion.set(version, [...list]);
467
+ }
468
+ } catch {
469
+ // Skip this integration's codemods; the validate-integration nudge
470
+ // above surfaces the underlying issue. Best-effort, non-blocking.
371
471
  }
372
- p.log.success('Already up to date — no codemods to run.');
373
- p.log.info('Use --force to run codemods anyway.');
374
- p.outro('Done');
375
- return;
376
472
  }
473
+ integrationVersionGroups = selectIntegrationCodemods(
474
+ integrationCodemodsByVersion,
475
+ currentVersion,
476
+ targetVersion,
477
+ );
478
+ const hasIntegrationCodemods = integrationVersionGroups.some(
479
+ g => g.codemods.length > 0,
480
+ );
377
481
 
378
- // Resolve transforms
379
- const versionManifests = [
380
- ...(await getTransformsBetween(currentVersion, targetVersion)),
381
- ...integrations.flatMap(integration =>
382
- normalizeIntegrationTransforms(
383
- integration,
384
- currentVersion,
385
- targetVersion,
386
- ),
387
- ),
388
- ];
482
+ // Add integration transforms to the run counts.
483
+ for (const {codemods} of integrationVersionGroups) {
484
+ for (const c of codemods) {
485
+ if (options.codemod && c.id !== options.codemod) continue;
486
+ if (skipCodemods.has(c.id)) continue;
487
+ if (c.codemod.isOptional && !options.codemod) {
488
+ totalOptional++;
489
+ } else {
490
+ totalTransforms++;
491
+ }
492
+ }
493
+ }
389
494
 
390
- if (versionManifests.length === 0) {
495
+ // No codemods at all for this range (neither core nor integration).
496
+ if (versionManifests.length === 0 && !hasIntegrationCodemods) {
391
497
  if (json) {
392
498
  return jsonOut('upgrade.status', {
393
499
  status: 'no_codemods',
@@ -400,20 +506,7 @@ export function registerUpgrade(program) {
400
506
  return;
401
507
  }
402
508
 
403
- // Count transforms (optional codemods only count when explicitly requested)
404
- let totalTransforms = 0;
405
- let totalOptional = 0;
406
- for (const {transforms} of versionManifests) {
407
- for (const t of transforms) {
408
- if (options.codemod && t.name !== options.codemod) continue;
409
- if (t.optional && !options.codemod) {
410
- totalOptional++;
411
- } else {
412
- totalTransforms++;
413
- }
414
- }
415
- }
416
-
509
+ // A named `--codemod` that matched nothing (across core + integration).
417
510
  if (totalTransforms === 0 && totalOptional === 0) {
418
511
  const msg = `Codemod "${options.codemod}" not found. Use --list to see available codemods.`;
419
512
  if (json)
@@ -442,54 +535,73 @@ export function registerUpgrade(program) {
442
535
  agentDocsRefreshed: false,
443
536
  };
444
537
 
445
- // Ensure jscodeshift is available
446
- const ready = await ensureJscodeshift({
447
- installDeps: options.installDeps,
448
- silent: json,
449
- });
450
- if (!ready) {
451
- if (json)
452
- return jsonError(
453
- 'jscodeshift is required but could not be installed.',
454
- undefined,
455
- ERROR_CODES.ERR_DEP_MISSING,
456
- );
457
- p.outro('Aborted');
458
- process.exitCode = 1;
459
- return;
538
+ // Run file-based integration codemods alongside the core registry
539
+ // codemods (config codemods first, then code codemods), ordered by
540
+ // version. Their results are merged into the receipt below.
541
+ let integrationResult = null;
542
+ if (hasIntegrationCodemods) {
543
+ if (!json) p.log.step('Applying integration codemods...');
544
+ const jscodeshift = (await import('jscodeshift')).default;
545
+ integrationResult = runIntegrationCodemods(integrationVersionGroups, {
546
+ apply: options.apply,
547
+ path: options.path,
548
+ codemod: options.codemod,
549
+ skipCodemods,
550
+ jscodeshift,
551
+ silent: json,
552
+ });
460
553
  }
461
554
 
462
- // Run codemods
463
- const codemodResult = await runCodemods(versionManifests, {
464
- apply: options.apply,
465
- path: options.path,
466
- codemod: options.codemod,
467
- silent: json,
468
- });
469
555
 
470
- if (options.apply && integrations.length > 0) {
471
- const codemodDir = path.resolve(options.path);
472
- const absoluteChangedFiles = uniqueFiles(
473
- codemodResult?.writtenFiles ?? [],
474
- );
475
- const changedFiles = absoluteChangedFiles.map(file =>
556
+ // Merge core + integration codemod results into a single accounting so
557
+ // hooks, receipts, and error gating see both.
558
+ const mergedFilesChanged =
559
+ (codemodResult?.totalFilesChanged ?? 0) +
560
+ (integrationResult?.totalFilesChanged ?? 0);
561
+ const mergedTransformsApplied =
562
+ (codemodResult?.totalTransformsApplied ?? 0) +
563
+ (integrationResult?.totalTransformsApplied ?? 0);
564
+ const mergedWrittenFiles = [
565
+ ...(codemodResult?.writtenFiles ?? []),
566
+ ...(integrationResult?.writtenFiles ?? []),
567
+ ];
568
+ const mergedErrors = [
569
+ ...(codemodResult?.errors ?? []),
570
+ ...(integrationResult?.errors ?? []),
571
+ ];
572
+
573
+ // Post-codemod hooks come from the app config (config.hooks.postCodemod).
574
+ // They run only when codemods actually changed files. In apply mode the
575
+ // commands execute (nonzero exit fails the upgrade); in dry-run mode we
576
+ // only preview the resolved command (a buildCommand throw still fails).
577
+ const changedFileCount = mergedFilesChanged;
578
+ if (postCodemodHooks.length > 0 && changedFileCount > 0) {
579
+ const absoluteChangedFiles = uniqueFiles(mergedWrittenFiles);
580
+ const files = absoluteChangedFiles.map(file =>
476
581
  path.relative(process.cwd(), file),
477
582
  );
478
- const packageChangedFiles = absoluteChangedFiles
479
- .filter(file => file.startsWith(process.cwd() + path.sep))
480
- .map(file => path.relative(process.cwd(), file));
481
- await runPostCodemodHooks(
482
- integrations,
483
- {
484
- packageDir: process.cwd(),
485
- codemodDir,
486
- changedFiles,
487
- absoluteChangedFiles,
488
- packageChangedFiles,
489
- apply: options.apply,
490
- },
491
- json,
492
- );
583
+ try {
584
+ await runPostCodemodHooks(
585
+ postCodemodHooks,
586
+ {
587
+ packageDir: process.cwd(),
588
+ files,
589
+ apply: options.apply,
590
+ },
591
+ json,
592
+ );
593
+ } catch (err) {
594
+ if (json)
595
+ return jsonError(
596
+ `Post-codemod hook failed: ${err.message}`,
597
+ {receipt},
598
+ ERROR_CODES.ERR_CODEMOD_FAILED,
599
+ );
600
+ p.log.error(`Post-codemod hook failed: ${err.message}`);
601
+ p.outro('Upgrade failed');
602
+ process.exitCode = 1;
603
+ return;
604
+ }
493
605
  }
494
606
 
495
607
  // Refresh agent docs if any exist (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, etc.)
@@ -507,17 +619,15 @@ export function registerUpgrade(program) {
507
619
  } catch {
508
620
  if (!json) {
509
621
  p.log.warn(
510
- `Could not update agent docs. Run \`${getRunPrefix()} astryx agent-docs\` to update manually.`,
622
+ `Could not update agent docs. Run \`${getRunPrefix()} astryx init --features agents\` to update manually.`,
511
623
  );
512
624
  }
513
625
  }
514
626
  }
515
627
 
516
- if (codemodResult && typeof codemodResult === 'object') {
517
- receipt.filesChanged = codemodResult.totalFilesChanged ?? 0;
518
- receipt.transformsApplied = codemodResult.totalTransformsApplied ?? 0;
519
- receipt.errors = codemodResult.errors ?? [];
520
- }
628
+ receipt.filesChanged = mergedFilesChanged;
629
+ receipt.transformsApplied = mergedTransformsApplied;
630
+ receipt.errors = mergedErrors;
521
631
 
522
632
  if (receipt.errors?.length > 0) {
523
633
  const msg = `Upgrade completed with ${receipt.errors.length} codemod error${receipt.errors.length === 1 ? '' : 's'}.`;