@astryxdesign/cli 0.1.0-canary.cfbdec3 → 0.1.0-canary.d150e45

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.1.0-canary.cfbdec3",
3
+ "version": "0.1.0-canary.d150e45",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -54,9 +54,9 @@
54
54
  "jscodeshift": "^17.3.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "@astryxdesign/core": "0.1.0-canary.cfbdec3",
58
- "@astryxdesign/lab": "0.1.0-canary.cfbdec3",
59
- "@astryxdesign/theme-neutral": "0.1.0-canary.cfbdec3"
57
+ "@astryxdesign/core": "0.1.0-canary.d150e45",
58
+ "@astryxdesign/lab": "0.1.0-canary.d150e45",
59
+ "@astryxdesign/theme-neutral": "0.1.0-canary.d150e45"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "@astryxdesign/core": {
@@ -70,9 +70,9 @@
70
70
  }
71
71
  },
72
72
  "devDependencies": {
73
- "@astryxdesign/core": "0.1.0-canary.cfbdec3",
74
- "@astryxdesign/lab": "0.1.0-canary.cfbdec3",
75
- "@astryxdesign/theme-neutral": "0.1.0-canary.cfbdec3"
73
+ "@astryxdesign/core": "0.1.0-canary.d150e45",
74
+ "@astryxdesign/lab": "0.1.0-canary.d150e45",
75
+ "@astryxdesign/theme-neutral": "0.1.0-canary.d150e45"
76
76
  },
77
77
  "scripts": {
78
78
  "astryx": "node bin/astryx.mjs",
@@ -177,9 +177,16 @@ describe('--json contract: supported commands emit valid envelopes', () => {
177
177
  });
178
178
 
179
179
  it('astryx upgrade --json (already up to date) emits upgrade.status', () => {
180
- // Force a no-op range: from > to.
180
+ // Force a no-op range: from > installed target.
181
+ const coreDir = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
182
+ fs.mkdirSync(coreDir, {recursive: true});
183
+ fs.writeFileSync(
184
+ path.join(coreDir, 'package.json'),
185
+ JSON.stringify({name: '@astryxdesign/core', version: '0.0.1'}, null, 2),
186
+ );
187
+
181
188
  const {status, stdout} = runCli(
182
- ['upgrade', '--json', '--from', '99.0.0', '--to', '0.0.1'],
189
+ ['upgrade', '--json', '--from', '99.0.0'],
183
190
  {cwd: tmpDir},
184
191
  );
185
192
  expect(status).toBe(0);
@@ -3,31 +3,32 @@
3
3
  /**
4
4
  * @file upgrade command — Full version-to-version upgrade pipeline
5
5
  *
6
- * `astryx upgrade` detects the consumer's @astryxdesign/core version, bumps all
7
- * @astryxdesign/* dependencies, installs them, and runs codemods to migrate
8
- * breaking API changes.
6
+ * `astryx upgrade` runs codemods that migrate source code from a previous
7
+ * Astryx version to the currently installed version.
8
+ *
9
+ * Consumers should bump/install their Astryx packages first, then run:
10
+ * astryx upgrade --from <old-version> --path <source-dir> --apply
9
11
  *
10
12
  * Pipeline (--apply):
11
- * 1. Detect current version from package.json (or --from)
12
- * 2. Bump all @astryxdesign/* deps in package.json to --to version
13
- * 3. Run package manager install (yarn/npm/pnpm/bun)
14
- * 4. Run codemods for the version range
15
- * 5. Refresh agent docs (AGENTS.md / CLAUDE.md) if present
13
+ * 1. Read installed @astryxdesign/core (or legacy @xds/core) version
14
+ * 2. Run codemods for --from installed version
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
18
19
  * --apply Write changes to disk (default: dry-run)
19
- * --from <version> Previous version (overrides package.json detection)
20
- * --to <version> Target version (default: latest in registry)
21
- * --force Run codemods even if versions appear up to date
22
- * --codemod <name> Run a specific transform only (skips version check)
23
- * --codemod-only Skip version bump + install, run codemods only
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
24
23
  * --path <dir> Source directory (default: ./src)
25
24
  * --install-deps Auto-install jscodeshift without prompting (for CI/LLM)
26
25
  */
27
26
 
28
27
  import * as fs from 'node:fs';
29
28
  import * as path from 'node:path';
30
- import {execSync} from 'node:child_process';
29
+ import {pathToFileURL} from 'node:url';
30
+ import {execFile} from 'node:child_process';
31
+ import {promisify} from 'node:util';
31
32
  import * as p from '@clack/prompts';
32
33
  import {ensureJscodeshift} from '../codemods/ensure-jscodeshift.mjs';
33
34
  import {
@@ -36,89 +37,185 @@ import {
36
37
  } from '../codemods/registry.mjs';
37
38
  import {runCodemods} from '../codemods/runner.mjs';
38
39
  import {installAgentDocs, discoverAgentDocs} from './agent-docs.mjs';
39
- import {detectPackageManager, getRunPrefix} from '../utils/package-manager.mjs';
40
- import {isValidSemver, semverGte} from '../utils/semver.mjs';
40
+ import {getRunPrefix} from '../utils/package-manager.mjs';
41
+ import {isValidSemver, semverGte, semverGt} from '../utils/semver.mjs';
41
42
  import {jsonOut, jsonError} from '../lib/json.mjs';
42
43
  import {ERROR_CODES} from '../lib/error-codes.mjs';
43
44
 
45
+ const execFileAsync = promisify(execFile);
46
+
44
47
  /**
45
- * Detect the installed @astryxdesign/core version from the consumer's package.json.
46
- * @returns {string|null}
48
+ * Detect the installed target version from node_modules.
49
+ * @returns {{version: string, packageName: string}|null}
47
50
  */
48
- function detectCurrentVersion() {
49
- const pkgPath = path.resolve(process.cwd(), 'package.json');
50
- try {
51
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
52
- const deps = {
53
- ...pkg.peerDependencies,
54
- ...pkg.dependencies,
55
- ...pkg.devDependencies,
56
- };
57
- const version = deps['@astryxdesign/core'];
58
- if (!version) return null;
59
- // Strip semver range chars (^, ~, >=, etc.)
60
- return version.replace(/^[^\d]*/, '');
61
- } catch {
62
- return null;
51
+ function detectInstalledTargetVersion() {
52
+ for (const packageName of ['@astryxdesign/core', '@xds/core']) {
53
+ const pkgPath = path.resolve(
54
+ process.cwd(),
55
+ 'node_modules',
56
+ ...packageName.split('/'),
57
+ 'package.json',
58
+ );
59
+ try {
60
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
61
+ if (pkg.version) return {version: pkg.version, packageName};
62
+ } catch {
63
+ // Missing or unreadable package.json — try the next supported package name.
64
+ }
63
65
  }
66
+ return null;
64
67
  }
65
68
 
66
- /**
67
- * Bump all @astryxdesign/* dependencies in the consumer's package.json to the target version.
68
- * Preserves the existing semver range prefix (^, ~, etc.).
69
- *
70
- * @param {string} targetVersion - Version to bump to (e.g. '0.0.5')
71
- * @returns {{bumped: string[], pkgPath: string}|null} List of bumped package names, or null if no package.json
72
- */
73
- function bumpXdsDeps(targetVersion) {
74
- const pkgPath = path.resolve(process.cwd(), 'package.json');
75
- if (!fs.existsSync(pkgPath)) return null;
76
69
 
77
- const raw = fs.readFileSync(pkgPath, 'utf-8');
78
- const pkg = JSON.parse(raw);
79
- const bumped = [];
70
+ function isPathSpec(spec) {
71
+ return (
72
+ spec.startsWith('.') ||
73
+ spec.startsWith('/') ||
74
+ spec.endsWith('.mjs') ||
75
+ spec.endsWith('.js')
76
+ );
77
+ }
80
78
 
81
- for (const depField of ['dependencies', 'devDependencies']) {
82
- const deps = pkg[depField];
83
- if (!deps) continue;
79
+ function resolvePackageDir(packageName) {
80
+ const parts = packageName.split('/');
81
+ return path.resolve(process.cwd(), 'node_modules', ...parts);
82
+ }
84
83
 
85
- for (const name of Object.keys(deps)) {
86
- if (!name.startsWith('@astryxdesign/')) continue;
84
+ function resolveIntegrationFile(spec) {
85
+ if (isPathSpec(spec)) {
86
+ return path.resolve(process.cwd(), spec);
87
+ }
87
88
 
88
- const current = deps[name];
89
- // Preserve range prefix (^, ~, >=, etc.)
90
- const prefix = current.match(/^([^\d]*)/)?.[1] ?? '^';
91
- const newRange = `${prefix}${targetVersion}`;
89
+ const packageDir = resolvePackageDir(spec);
90
+ const pkgPath = path.join(packageDir, 'package.json');
91
+ let pkg;
92
+ try {
93
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
94
+ } catch {
95
+ throw new Error(
96
+ `Could not find installed integration package "${spec}" at ${pkgPath}. Install it first or pass a direct integration file path.`,
97
+ );
98
+ }
92
99
 
93
- if (current !== newRange) {
94
- deps[name] = newRange;
95
- bumped.push(name);
100
+ const manifestPath = pkg.astryx?.integration ?? pkg.xds?.integration;
101
+ if (!manifestPath) {
102
+ throw new Error(
103
+ `Package "${spec}" does not declare astryx.integration (or legacy xds.integration) in package.json.`,
104
+ );
105
+ }
106
+ return path.resolve(packageDir, manifestPath);
107
+ }
108
+
109
+ async function loadIntegrations(specs) {
110
+ const integrations = [];
111
+ for (const spec of specs) {
112
+ const file = resolveIntegrationFile(spec);
113
+ const mod = await import(pathToFileURL(file).href);
114
+ const integration = mod.default ?? mod.integration ?? mod;
115
+ if (!integration || typeof integration !== 'object') {
116
+ throw new Error(`Integration ${spec} did not export an object.`);
117
+ }
118
+ const integrationDir = path.dirname(file);
119
+ if (Array.isArray(integration.codemods)) {
120
+ for (const codemod of integration.codemods) {
121
+ if (typeof codemod.transform === 'string') {
122
+ const transformPath = path.resolve(integrationDir, codemod.transform);
123
+ const transformMod = await import(pathToFileURL(transformPath).href);
124
+ codemod.transform =
125
+ transformMod.default ?? transformMod.transform ?? transformMod;
126
+ }
96
127
  }
97
128
  }
129
+ integrations.push({
130
+ ...integration,
131
+ __file: file,
132
+ __dir: integrationDir,
133
+ __spec: spec,
134
+ });
98
135
  }
136
+ return integrations;
137
+ }
99
138
 
100
- if (bumped.length === 0) return {bumped: [], pkgPath};
139
+ function normalizeIntegrationTransforms(integration, from, to) {
140
+ const transforms = [];
141
+ for (const entry of integration.codemods ?? []) {
142
+ const entryFrom = entry.from ?? '0.0.0';
143
+ const entryTo = entry.to ?? to;
144
+ if (semverGte(from, entryTo) || semverGt(entryFrom, to)) continue;
145
+ if (!entry.name)
146
+ throw new Error(
147
+ `Integration ${integration.name ?? integration.__spec} has a codemod without a name.`,
148
+ );
149
+ if (!entry.transform)
150
+ throw new Error(`Integration codemod ${entry.name} is missing transform.`);
151
+ const directTransform =
152
+ typeof entry.transform === 'function' ? entry.transform : null;
153
+ if (!directTransform)
154
+ throw new Error(
155
+ `Integration codemod ${entry.name} did not resolve to a function.`,
156
+ );
157
+ transforms.push({
158
+ name: entry.name,
159
+ meta: {
160
+ title: entry.title ?? `${integration.name ?? integration.__spec}: ${entry.name}`,
161
+ description: entry.description ?? '',
162
+ pr: entry.pr,
163
+ fileExtensions: entry.fileExtensions,
164
+ },
165
+ optional: !!entry.optional,
166
+ transform: directTransform,
167
+ });
168
+ }
169
+ return transforms.length ? [{version: to, transforms}] : [];
170
+ }
101
171
 
102
- // Write back with same formatting (detect indent from original)
103
- const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' ';
104
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n');
105
- return {bumped, pkgPath};
172
+ function uniqueFiles(files) {
173
+ return [...new Set((files ?? []).filter(Boolean))];
106
174
  }
107
175
 
108
- /**
109
- * Get the install command for the detected package manager.
110
- * @param {boolean} force — pass --force to bust stale lockfile resolutions
111
- * @returns {string}
112
- */
113
- function getInstallCommand(force = false) {
114
- const pm = detectPackageManager();
115
- const forceFlag = force ? ' --force' : '';
116
- switch (pm) {
117
- case 'yarn': return `yarn install${forceFlag}`;
118
- case 'pnpm': return `pnpm install${force ? ' --force' : ''}`;
119
- case 'bun': return `bun install${force ? ' --force' : ''}`;
120
- case 'npm':
121
- default: return `npm install${force ? ' --force' : ''}`;
176
+ async function runPostCodemodHooks(integrations, context, silent) {
177
+ const hooks = integrations.flatMap(integration =>
178
+ (integration.postCodemod ?? []).map(hook => ({integration, hook})),
179
+ );
180
+ if (hooks.length === 0) return;
181
+
182
+ const log = silent
183
+ ? {info() {}, warn() {}, success() {}, error() {}}
184
+ : p.log;
185
+
186
+ const run = async (command, args, options = {}) => {
187
+ await execFileAsync(command, args, {
188
+ cwd: options.cwd ?? context.packageDir,
189
+ timeout: options.timeoutMs ?? 300_000,
190
+ stdio: 'pipe',
191
+ encoding: 'utf-8',
192
+ env: {...process.env, ...(options.env ?? {})},
193
+ });
194
+ };
195
+
196
+ const ctx = {...context, run};
197
+ for (const {integration, hook} of hooks) {
198
+ const label = `${integration.name ?? integration.__spec}:${hook.name ?? 'postCodemod'}`;
199
+ try {
200
+ if (typeof hook.run === 'function') {
201
+ await hook.run(ctx);
202
+ } else if (typeof hook.command === 'function') {
203
+ const cmd = await hook.command(ctx);
204
+ if (cmd) {
205
+ await run(cmd.command, cmd.args ?? [], {
206
+ cwd: cmd.cwd,
207
+ timeoutMs: cmd.timeoutMs,
208
+ env: cmd.env,
209
+ });
210
+ }
211
+ } else {
212
+ log.warn(`Integration hook ${label} has no run() or command() function; skipping.`);
213
+ continue;
214
+ }
215
+ log.success(`Post-codemod hook ${label} completed.`);
216
+ } catch (err) {
217
+ log.warn(`Post-codemod hook ${label} failed: ${err.message}`);
218
+ }
122
219
  }
123
220
  }
124
221
 
@@ -129,14 +226,16 @@ export function registerUpgrade(program) {
129
226
  program
130
227
  .command('upgrade')
131
228
  .description('Run codemods to migrate between versions')
229
+ .option('--from <version>', 'Previous version before the dependency upgrade')
132
230
  .option('--apply', 'Write changes to disk (default: dry-run)', false)
133
- .option('--from <version>', 'Previous version (overrides package.json detection)')
134
- .option('--to <version>', 'Target version', latestVersion)
135
- .option('--force', 'Run codemods even if versions appear up to date', false)
231
+ .option('--force', 'Run codemods even if --from is newer than the installed version', false)
136
232
  .option('--codemod <name>', 'Run a specific transform only')
137
- .option('--codemod-only', 'Skip version bump and install, run codemods only', false)
138
- .option('--skip-install', 'Skip package manager install after bumping deps', false)
139
- .option('--force-install', 'Pass --force to package manager install (busts stale lockfile resolutions)', false)
233
+ .option(
234
+ '--integration <package-or-file>',
235
+ 'Explicit integration package name or integration file path (repeatable)',
236
+ (value, previous) => [...(previous ?? []), value],
237
+ [],
238
+ )
140
239
  .option('--path <dir>', 'Source directory to scan', './src')
141
240
  .option('--install-deps', 'Auto-install jscodeshift without prompting', false)
142
241
  .option('--list', 'List available codemods', false)
@@ -144,18 +243,17 @@ export function registerUpgrade(program) {
144
243
  const json = program.opts().json || false;
145
244
  if (!json) p.intro('Upgrade');
146
245
 
147
- // Validate --to / --from upfront so callers don't silently accept
148
- // typos like `--to bogus` (which used to flow through getTransformsBetween
149
- // and just emit "no codemods available").
150
- if (options.to !== undefined && !isValidSemver(options.to)) {
151
- const msg = `Invalid --to value: "${options.to}". Expected a semver string like 0.0.10.`;
152
- if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_VERSION);
246
+ if (!options.list && !options.from) {
247
+ const msg = 'Missing required --from. Install the target version first, then run `astryx upgrade --from <old-version>`.';
248
+ if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
153
249
  p.log.error(msg);
154
250
  p.outro('Aborted');
155
251
  process.exitCode = 1;
156
252
  return;
157
253
  }
158
- if (options.from !== undefined && !isValidSemver(options.from)) {
254
+
255
+ // Validate --from upfront so callers don't silently accept typos.
256
+ if (!options.list && !isValidSemver(options.from)) {
159
257
  const msg = `Invalid --from value: "${options.from}". Expected a semver string like 0.0.5.`;
160
258
  if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_VERSION);
161
259
  p.log.error(msg);
@@ -184,60 +282,66 @@ export function registerUpgrade(program) {
184
282
  return;
185
283
  }
186
284
 
187
- // When --codemod is specified, skip version detection entirely —
188
- // the user asked for a specific transform, just run it.
189
- const skipVersionCheck = !!options.codemod;
190
-
191
- // --codemod-only skips version bump + install but still uses --from/--to
192
- // for codemod resolution. Useful for canary testing or running codemods
193
- // independently of dependency changes.
194
- const skipBump = options.codemodOnly || skipVersionCheck;
195
-
196
- // Detect current version (--from overrides package.json)
197
- const currentVersion = options.from ?? detectCurrentVersion();
198
- if (!currentVersion && !skipVersionCheck) {
199
- const msg = 'Could not detect @astryxdesign/core version. Make sure package.json is in the current directory, or use --from <version>.';
285
+ const currentVersion = options.from;
286
+ const installed = detectInstalledTargetVersion();
287
+ if (!installed) {
288
+ const msg = 'Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun `astryx upgrade --from <old-version>`.';
200
289
  if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_VERSION_DETECT);
201
290
  p.log.error(msg);
202
291
  p.outro('Aborted');
203
292
  process.exitCode = 1;
204
293
  return;
205
294
  }
295
+ const targetVersion = installed.version;
206
296
 
207
- const targetVersion = options.to;
297
+ if (!json) {
298
+ p.log.info(`From version: ${currentVersion}`);
299
+ p.log.info(`Installed target: ${targetVersion} (${installed.packageName})`);
300
+ }
208
301
 
209
- if (!skipVersionCheck) {
210
- if (!json) {
211
- p.log.info(`Current version: ${currentVersion}`);
212
- p.log.info(`Target version: ${targetVersion}`);
213
- }
302
+ let integrations;
303
+ try {
304
+ integrations = await loadIntegrations(options.integration ?? []);
305
+ } catch (err) {
306
+ if (json) return jsonError(err.message, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
307
+ p.log.error(err.message);
308
+ p.outro('Aborted');
309
+ process.exitCode = 1;
310
+ return;
311
+ }
312
+ if (!json && integrations.length > 0) {
313
+ p.log.info(
314
+ `Integrations: ${integrations.map(i => i.name ?? i.__spec).join(', ')}`,
315
+ );
316
+ }
214
317
 
215
- if (!options.force && semverGte(currentVersion, targetVersion)) {
216
- if (json) {
217
- return jsonOut('upgrade.status', {
218
- status: 'up_to_date',
219
- from: currentVersion,
220
- to: targetVersion,
221
- });
222
- }
223
- p.log.success('Already up to date — no codemods to run.');
224
- p.log.info('Use --force to run codemods anyway, or --from <version> to specify the previous version.');
225
- p.outro('Done');
226
- return;
318
+ if (!options.force && semverGte(currentVersion, targetVersion)) {
319
+ if (json) {
320
+ return jsonOut('upgrade.status', {
321
+ status: 'up_to_date',
322
+ from: currentVersion,
323
+ to: targetVersion,
324
+ });
227
325
  }
326
+ p.log.success('Already up to date — no codemods to run.');
327
+ p.log.info('Use --force to run codemods anyway.');
328
+ p.outro('Done');
329
+ return;
228
330
  }
229
331
 
230
332
  // Resolve transforms
231
- const versionManifests = await getTransformsBetween(
232
- skipVersionCheck ? '0.0.0' : currentVersion,
233
- targetVersion,
234
- );
333
+ const versionManifests = [
334
+ ...(await getTransformsBetween(currentVersion, targetVersion)),
335
+ ...integrations.flatMap(integration =>
336
+ normalizeIntegrationTransforms(integration, currentVersion, targetVersion),
337
+ ),
338
+ ];
235
339
 
236
340
  if (versionManifests.length === 0) {
237
341
  if (json) {
238
342
  return jsonOut('upgrade.status', {
239
343
  status: 'no_codemods',
240
- from: skipVersionCheck ? null : currentVersion,
344
+ from: currentVersion,
241
345
  to: targetVersion,
242
346
  });
243
347
  }
@@ -279,29 +383,7 @@ export function registerUpgrade(program) {
279
383
  }
280
384
  }
281
385
 
282
- const receipt = {from: currentVersion, to: targetVersion, codemods: totalTransforms, depsUpdated: [], agentDocsRefreshed: false};
283
-
284
- // Bump @astryxdesign/* deps and install before running codemods
285
- if (options.apply && !skipBump) {
286
- const result = bumpXdsDeps(targetVersion);
287
- if (result && result.bumped.length > 0) {
288
- receipt.depsUpdated = result.bumped;
289
- if (!json) p.log.info(`Bumped ${result.bumped.join(', ')} → ${targetVersion}`);
290
-
291
- const installCmd = getInstallCommand(options.forceInstall);
292
- if (options.skipInstall) {
293
- if (!json) p.log.info('Skipping install (--skip-install). Run your package manager manually.');
294
- } else {
295
- if (!json) p.log.step(`Running ${installCmd}...`);
296
- try {
297
- execSync(installCmd, {stdio: 'inherit', cwd: process.cwd()});
298
- if (!json) p.log.success('Dependencies installed.');
299
- } catch {
300
- if (!json) p.log.warn('Install failed — codemods will still run against existing code.');
301
- }
302
- }
303
- }
304
- }
386
+ const receipt = {from: currentVersion, to: targetVersion, codemods: totalTransforms, integrations: integrations.map(i => i.name ?? i.__spec), agentDocsRefreshed: false};
305
387
 
306
388
  // Ensure jscodeshift is available
307
389
  const ready = await ensureJscodeshift({installDeps: options.installDeps, silent: json});
@@ -320,6 +402,29 @@ export function registerUpgrade(program) {
320
402
  silent: json,
321
403
  });
322
404
 
405
+ if (options.apply && integrations.length > 0) {
406
+ const codemodDir = path.resolve(options.path);
407
+ const absoluteChangedFiles = uniqueFiles(codemodResult?.writtenFiles ?? []);
408
+ const changedFiles = absoluteChangedFiles.map(file =>
409
+ path.relative(process.cwd(), file),
410
+ );
411
+ const packageChangedFiles = absoluteChangedFiles
412
+ .filter(file => file.startsWith(process.cwd() + path.sep))
413
+ .map(file => path.relative(process.cwd(), file));
414
+ await runPostCodemodHooks(
415
+ integrations,
416
+ {
417
+ packageDir: process.cwd(),
418
+ codemodDir,
419
+ changedFiles,
420
+ absoluteChangedFiles,
421
+ packageChangedFiles,
422
+ apply: options.apply,
423
+ },
424
+ json,
425
+ );
426
+ }
427
+
323
428
  // Refresh agent docs if any exist (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, etc.)
324
429
  // Always update after --apply; also update during dry-run if files exist,
325
430
  // since the index reflects the installed CLI version, not the codemods.
@@ -6,7 +6,6 @@ import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
7
  import {Command} from 'commander';
8
8
  import {registerUpgrade} from './upgrade.mjs';
9
- import {latestVersion} from '../codemods/registry.mjs';
10
9
 
11
10
  let tmpDir;
12
11
  let originalCwd;
@@ -53,13 +52,28 @@ function createProgram() {
53
52
  return program;
54
53
  }
55
54
 
56
- function writePkg(deps) {
55
+ function writePkg(deps = {}) {
57
56
  fs.writeFileSync(
58
57
  path.join(tmpDir, 'package.json'),
59
58
  JSON.stringify({name: 'fixture', dependencies: deps}, null, 2),
60
59
  );
61
60
  }
62
61
 
62
+ function writeInstalledCore(version, packageName = '@astryxdesign/core') {
63
+ const parts = packageName.split('/');
64
+ const dir = path.join(tmpDir, 'node_modules', ...parts);
65
+ fs.mkdirSync(dir, {recursive: true});
66
+ fs.writeFileSync(
67
+ path.join(dir, 'package.json'),
68
+ JSON.stringify({name: packageName, version}, null, 2),
69
+ );
70
+ }
71
+
72
+ function writeSourceFile() {
73
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
74
+ fs.writeFileSync(path.join(tmpDir, 'src', 'index.ts'), 'const x = 1;\n');
75
+ }
76
+
63
77
  /** Run a command and capture the parsed JSON response (last printed JSON line). */
64
78
  async function runJson(args) {
65
79
  const program = createProgram();
@@ -83,53 +97,53 @@ async function runJson(args) {
83
97
  }
84
98
 
85
99
  describe('upgrade gate (semver comparison)', () => {
86
- it('does NOT block an upgrade from 0.0.9 to 0.0.10 (regression)', async () => {
100
+ it('does NOT block an upgrade from 0.0.9 to installed 0.0.10 (regression)', async () => {
87
101
  // The original bug: string compare said '0.0.9' >= '0.0.10', so the
88
102
  // gate told users "Already up to date" without --force.
89
- writePkg({'@astryxdesign/core': '^0.0.9'});
103
+ writePkg();
104
+ writeInstalledCore('0.0.10');
105
+ writeSourceFile();
90
106
 
91
- const result = await runJson(['--json', 'upgrade', '--to', '0.0.10', '--codemod-only']);
92
- // Either a real run or "no codemods available" — but never the
93
- // up-to-date short-circuit (which has no `type` field).
107
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.9', '--path', 'src']);
94
108
  expect(result).not.toBeNull();
95
- // The receipt or "no codemods" path should not look like the
96
- // up-to-date short-circuit (which would return without printing JSON).
97
109
  expect(result.type === 'upgrade.run' || result.error || logCalls.some(l => l.includes('No codemods'))).toBeTruthy();
98
110
  });
99
111
 
100
- it('blocks when current >= target by semver (e.g. 0.0.10 → 0.0.9)', async () => {
101
- writePkg({'@astryxdesign/core': '^0.0.10'});
112
+ it('blocks when --from >= installed target by semver (e.g. 0.0.10 → 0.0.9)', async () => {
113
+ writePkg();
114
+ writeInstalledCore('0.0.9');
102
115
  const program = createProgram();
103
- await program.parseAsync(['node', 'astryx', 'upgrade', '--to', '0.0.9']);
116
+ await program.parseAsync(['node', 'astryx', 'upgrade', '--from', '0.0.10']);
104
117
  const output = stdoutCalls.join('') + logCalls.join('\n');
105
118
  expect(output).toMatch(/up to date|Already/i);
106
119
  });
107
120
  });
108
121
 
109
- describe('upgrade --to validation', () => {
110
- it('rejects bogus --to values with a structured error', async () => {
111
- writePkg({'@astryxdesign/core': '^0.0.5'});
112
- const result = await runJson(['--json', 'upgrade', '--to', 'bogus']);
122
+ describe('upgrade argument validation', () => {
123
+ it('requires --from for upgrade runs', async () => {
124
+ writePkg();
125
+ writeInstalledCore('0.0.15');
126
+ const result = await runJson(['--json', 'upgrade']);
113
127
  expect(result).not.toBeNull();
114
- expect(result.error).toMatch(/Invalid --to/);
128
+ expect(result.error).toMatch(/Missing required --from/);
115
129
  expect(exitCode).toBe(1);
116
130
  });
117
131
 
118
132
  it('rejects bogus --from values', async () => {
119
- writePkg({'@astryxdesign/core': '^0.0.5'});
120
- const result = await runJson(['--json', 'upgrade', '--from', 'not-a-version', '--to', '0.0.5']);
133
+ writePkg();
134
+ writeInstalledCore('0.0.15');
135
+ const result = await runJson(['--json', 'upgrade', '--from', 'not-a-version']);
121
136
  expect(result).not.toBeNull();
122
137
  expect(result.error).toMatch(/Invalid --from/);
123
138
  expect(exitCode).toBe(1);
124
139
  });
125
140
 
126
- it('accepts a valid semver --to', async () => {
127
- writePkg({'@astryxdesign/core': '^0.0.5'});
128
- // Don't actually run codemods — codemod-only + a target with no
129
- // matching transforms is enough to confirm validation passed.
130
- const result = await runJson(['--json', 'upgrade', '--to', latestVersion, '--codemod-only']);
131
- // No "Invalid --to" error means validation passed.
132
- expect(result?.error || '').not.toMatch(/Invalid --to/);
141
+ it('detects the installed target version from @astryxdesign/core', async () => {
142
+ writePkg();
143
+ writeInstalledCore('0.0.15');
144
+ writeSourceFile();
145
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.14', '--path', 'src']);
146
+ expect(result?.error || '').not.toMatch(/Could not find installed/);
133
147
  });
134
148
  });
135
149