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

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/README.md CHANGED
@@ -11,7 +11,7 @@ npx astryx docs migration
11
11
  npx astryx template --list
12
12
  ```
13
13
 
14
- ### Finding things: `astryx search`
14
+ ## Finding things: `astryx search`
15
15
 
16
16
  When you don't know whether what you need is a component, a hook, a docs topic,
17
17
  or a template, search across all of them at once. Results are ranked by
@@ -50,20 +50,20 @@ Options:
50
50
 
51
51
  ## Commands
52
52
 
53
- | Command | Description |
54
- | ------------- | --------------------------------------------------------------------------------------- |
53
+ | Command | Description |
54
+ | ------------- | ---------------------------------------------------------------------------------------------------- |
55
55
  | `init` | Initialize the design system in your project: installs packages, sets up theming, adds AI agent docs |
56
- | `component` | List components or print detailed docs, props, usage examples, and source |
57
- | `search` | Find components, hooks, docs, and templates in one ranked, cross-domain result set |
58
- | `docs` | Print reference documentation (tokens, theme, color, typography, spacing, etc.) |
59
- | `template` | Inject page or block templates into your project |
60
- | `hook` | List hooks and print hook documentation |
61
- | `swizzle` | Copy component source into your project for deep customization |
62
- | `upgrade` | Run codemods to migrate between versions |
63
- | `theme build` | Compile a defineTheme file to production CSS and JS |
64
- | `discover` | Discover external packages and components |
65
- | `gap-report` | Report a gap when a component doesn't meet your needs |
66
- | `doctor` | Diagnose your XDS setup and report problems with fixes (CI-friendly via exit code) |
56
+ | `component` | List components or print detailed docs, props, usage examples, and source |
57
+ | `search` | Find components, hooks, docs, and templates in one ranked, cross-domain result set |
58
+ | `docs` | Print reference documentation (tokens, theme, color, typography, spacing, etc.) |
59
+ | `template` | Inject page or block templates into your project |
60
+ | `hook` | List hooks and print hook documentation |
61
+ | `swizzle` | Copy component source into your project for deep customization |
62
+ | `upgrade` | Run codemods to migrate between versions |
63
+ | `theme build` | Compile a defineTheme file to production CSS and JS |
64
+ | `discover` | Discover external packages and components |
65
+ | `gap-report` | Report a gap when a component doesn't meet your needs |
66
+ | `doctor` | Diagnose your XDS setup and report problems with fixes (CI-friendly via exit code) |
67
67
 
68
68
  ### Global options
69
69
 
@@ -122,46 +122,46 @@ if (isError(result)) {
122
122
 
123
123
  ### Error codes
124
124
 
125
- | Code | Meaning |
126
- | --- | --- |
127
- | `ERR_UNKNOWN` | Generic fallback for any error without a more specific code. |
128
- | `ERR_UNKNOWN_COMMAND` | A top-level command name was not recognized (e.g. `astryx bogus`). |
129
- | `ERR_UNKNOWN_SUBCOMMAND` | A subcommand under a group was not recognized (e.g. `astryx theme bogus`). |
130
- | `ERR_INVALID_OPTION` | An unknown flag was passed, or `--json` was used on a command that doesn't support it. |
131
- | `ERR_INVALID_ARGUMENT` | An option/argument value was rejected, or required flags were missing. |
132
- | `ERR_MISSING_ARGUMENT` | A required positional argument was omitted (e.g. `astryx theme build` with no file). |
133
- | `ERR_INVALID_LANG` | `--lang` was given a value outside its choices (`en`, `zh`, `dense`). |
134
- | `ERR_INVALID_DETAIL` | `--detail` was given a value outside its choices (`full`, `compact`, `brief`). |
135
- | `ERR_NODE_VERSION` | The running Node.js version is below the supported minimum. |
136
- | `ERR_CORE_NOT_FOUND` | `@astryxdesign/core` could not be located (not installed / not in a monorepo). |
137
- | `ERR_UNKNOWN_COMPONENT` | No component matched the requested name. |
138
- | `ERR_UNKNOWN_HOOK` | No hook matched the requested name. |
139
- | `ERR_UNKNOWN_TOPIC` | No docs topic matched the requested name. |
140
- | `ERR_UNKNOWN_SECTION` | A docs topic exists but the requested section within it does not. |
141
- | `ERR_UNKNOWN_CATEGORY` | A `--category` filter value did not match any known category. |
142
- | `ERR_UNKNOWN_TEMPLATE` | No template matched the requested name. |
143
- | `ERR_UNKNOWN_PACKAGE` | No package matched the requested name (discover). |
144
- | `ERR_UNKNOWN_AGENT` | An unrecognized `--agent` value was passed (agent docs / init). |
145
- | `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to `init`. |
146
- | `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). |
147
- | `ERR_NOT_FOUND` | A discover/lookup query matched nothing in any package. |
148
- | `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. |
149
- | `ERR_NO_SHOWCASE` | No showcase exists for the requested component. |
150
- | `ERR_NO_SOURCE` | No source file could be located for the component/template. |
151
- | `ERR_INVALID_DOC` | A component's docs failed validation (malformed `.doc.mjs`). |
152
- | `ERR_FILE_NOT_FOUND` | A required input file did not exist. |
153
- | `ERR_FILE_EXISTS` | Refused to overwrite an existing file in non-interactive mode. |
154
- | `ERR_PATH_TRAVERSAL` | A path escaped its allowed root, or a name contained traversal markers. |
155
- | `ERR_WRITE_FAILED` | Writing output files failed (and was rolled back). |
156
- | `ERR_THEME_INVALID` | A theme definition was missing a required property (e.g. `name`). |
157
- | `ERR_THEME_LOAD` | A theme file could not be loaded / parsed into a `defineTheme` result. |
158
- | `ERR_TEMPLATE_CONFIG` | `template.get` is not configured in `astryx.config.mjs` (fetch-by-id). |
159
- | `ERR_TEMPLATE_GET` | A configured `template.get` threw or returned an invalid value. |
160
- | `ERR_VERSION_DETECT` | The current `@astryxdesign/core` version could not be detected. |
161
- | `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. |
162
- | `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. |
163
- | `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. |
164
- | `ERR_GAP_REPORT_FAILED` | Filing a gap report failed (disabled, or the integration errored). |
125
+ | Code | Meaning |
126
+ | ------------------------ | -------------------------------------------------------------------------------------- |
127
+ | `ERR_UNKNOWN` | Generic fallback for any error without a more specific code. |
128
+ | `ERR_UNKNOWN_COMMAND` | A top-level command name was not recognized (e.g. `astryx bogus`). |
129
+ | `ERR_UNKNOWN_SUBCOMMAND` | A subcommand under a group was not recognized (e.g. `astryx theme bogus`). |
130
+ | `ERR_INVALID_OPTION` | An unknown flag was passed, or `--json` was used on a command that doesn't support it. |
131
+ | `ERR_INVALID_ARGUMENT` | An option/argument value was rejected, or required flags were missing. |
132
+ | `ERR_MISSING_ARGUMENT` | A required positional argument was omitted (e.g. `astryx theme build` with no file). |
133
+ | `ERR_INVALID_LANG` | `--lang` was given a value outside its choices (`en`, `zh`, `dense`). |
134
+ | `ERR_INVALID_DETAIL` | `--detail` was given a value outside its choices (`full`, `compact`, `brief`). |
135
+ | `ERR_NODE_VERSION` | The running Node.js version is below the supported minimum. |
136
+ | `ERR_CORE_NOT_FOUND` | `@astryxdesign/core` could not be located (not installed / not in a monorepo). |
137
+ | `ERR_UNKNOWN_COMPONENT` | No component matched the requested name. |
138
+ | `ERR_UNKNOWN_HOOK` | No hook matched the requested name. |
139
+ | `ERR_UNKNOWN_TOPIC` | No docs topic matched the requested name. |
140
+ | `ERR_UNKNOWN_SECTION` | A docs topic exists but the requested section within it does not. |
141
+ | `ERR_UNKNOWN_CATEGORY` | A `--category` filter value did not match any known category. |
142
+ | `ERR_UNKNOWN_TEMPLATE` | No template matched the requested name. |
143
+ | `ERR_UNKNOWN_PACKAGE` | No package matched the requested name (discover). |
144
+ | `ERR_UNKNOWN_AGENT` | An unrecognized `--agent` value was passed (agent docs / init). |
145
+ | `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to `init`. |
146
+ | `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). |
147
+ | `ERR_NOT_FOUND` | A discover/lookup query matched nothing in any package. |
148
+ | `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. |
149
+ | `ERR_NO_SHOWCASE` | No showcase exists for the requested component. |
150
+ | `ERR_NO_SOURCE` | No source file could be located for the component/template. |
151
+ | `ERR_INVALID_DOC` | A component's docs failed validation (malformed `.doc.mjs`). |
152
+ | `ERR_FILE_NOT_FOUND` | A required input file did not exist. |
153
+ | `ERR_FILE_EXISTS` | Refused to overwrite an existing file in non-interactive mode. |
154
+ | `ERR_PATH_TRAVERSAL` | A path escaped its allowed root, or a name contained traversal markers. |
155
+ | `ERR_WRITE_FAILED` | Writing output files failed (and was rolled back). |
156
+ | `ERR_THEME_INVALID` | A theme definition was missing a required property (e.g. `name`). |
157
+ | `ERR_THEME_LOAD` | A theme file could not be loaded / parsed into a `defineTheme` result. |
158
+ | `ERR_TEMPLATE_CONFIG` | `template.get` is not configured in `astryx.config.mjs` (fetch-by-id). |
159
+ | `ERR_TEMPLATE_GET` | A configured `template.get` threw or returned an invalid value. |
160
+ | `ERR_VERSION_DETECT` | The current `@astryxdesign/core` version could not be detected. |
161
+ | `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. |
162
+ | `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. |
163
+ | `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. |
164
+ | `ERR_GAP_REPORT_FAILED` | Filing a gap report failed (disabled, or the integration errored). |
165
165
 
166
166
  ## Capability manifest (agent discovery)
167
167
 
@@ -186,25 +186,59 @@ Shape:
186
186
  "version": "0.0.14",
187
187
  "description": "Design system CLI — components, themes, and tooling",
188
188
  "globalOptions": [
189
- {"flag": "--json", "type": "boolean", "description": "Output as typed JSON…"},
190
- {"flag": "--lang <locale>", "type": "enum", "choices": ["en", "zh", "dense"]},
191
- {"flag": "--detail <level>", "type": "enum", "choices": ["full", "compact", "brief"], "default": "full"}
189
+ {
190
+ "flag": "--json",
191
+ "type": "boolean",
192
+ "description": "Output as typed JSON…",
193
+ },
194
+ {
195
+ "flag": "--lang <locale>",
196
+ "type": "enum",
197
+ "choices": ["en", "zh", "dense"],
198
+ },
199
+ {
200
+ "flag": "--detail <level>",
201
+ "type": "enum",
202
+ "choices": ["full", "compact", "brief"],
203
+ "default": "full",
204
+ },
192
205
  ],
193
206
  "commands": [
194
207
  {
195
208
  "name": "component",
196
209
  "description": "List components or print component docs",
197
- "arguments": [{"name": "name", "required": false, "variadic": false, "description": ""}],
198
- "options": [{"flag": "--props", "type": "boolean", "description": "Print only the props table"}],
210
+ "arguments": [
211
+ {
212
+ "name": "name",
213
+ "required": false,
214
+ "variadic": false,
215
+ "description": "",
216
+ },
217
+ ],
218
+ "options": [
219
+ {
220
+ "flag": "--props",
221
+ "type": "boolean",
222
+ "description": "Print only the props table",
223
+ },
224
+ ],
199
225
  "json": true,
200
- "responseTypes": ["component.list", "component.detail", "component.detail.props", "…"],
201
- "examples": ["astryx component Button --props --json"]
202
- }
226
+ "responseTypes": [
227
+ "component.list",
228
+ "component.detail",
229
+ "component.detail.props",
230
+ "…",
231
+ ],
232
+ "examples": ["astryx component Button --props --json"],
233
+ },
203
234
  // …one entry per command; subcommands (e.g. `theme build`) nest under `subcommands`
204
235
  ],
205
236
  "jsonSupported": ["component", "docs", "…"],
206
- "responseTypes": {"component": ["component.list", "…"], "theme build": ["theme.build"]}
207
- }
237
+ "responseTypes": {
238
+ "component": ["component.list", "…"],
239
+ "theme build": ["theme.build"],
240
+ },
241
+ },
208
242
  }
209
243
  ```
210
244
 
@@ -225,7 +259,15 @@ For the standalone manifest envelope (`type: "manifest"`), use `astryx manifest
225
259
  The same logic that powers `xds --json` is available as importable, type-safe functions:
226
260
 
227
261
  ```typescript
228
- import {component, docs, discover, template, hook, search, AstryxError} from '@astryxdesign/cli/api';
262
+ import {
263
+ component,
264
+ docs,
265
+ discover,
266
+ template,
267
+ hook,
268
+ search,
269
+ AstryxError,
270
+ } from '@astryxdesign/cli/api';
229
271
 
230
272
  // Same result as: xds --json component Button
231
273
  const btn = await component('Button');
@@ -359,16 +401,16 @@ No failures — but review the ⚠ warnings above when you can.
359
401
 
360
402
  ### Checks
361
403
 
362
- | Check | Status it can return | What it verifies |
363
- | -------------------- | -------------------- | ----------------------------------------------------------- |
364
- | Node.js version | pass / fail | Running Node meets the CLI's minimum |
365
- | @astryxdesign/core installed | pass / fail | `@astryxdesign/core` is resolvable from the project |
366
- | Version alignment | pass / warn / info | Installed `@astryxdesign/core` is in step with `@astryxdesign/cli` |
367
- | Theme packages | pass / warn | An `@astryxdesign/theme-*` package is installed and a theme is wired |
368
- | astryx.config.mjs | pass / fail / info | Config (if present) loads cleanly with a valid shape |
369
- | AI agent docs | pass / warn / info | Agent docs exist and contain the XDS section markers |
370
- | Peer dependencies | pass / warn / info | `@astryxdesign/core`'s peer deps (react, …) are installed |
371
- | Package manager | info | Reports the detected package manager |
404
+ | Check | Status it can return | What it verifies |
405
+ | ---------------------------- | -------------------- | -------------------------------------------------------------------- |
406
+ | Node.js version | pass / fail | Running Node meets the CLI's minimum |
407
+ | @astryxdesign/core installed | pass / fail | `@astryxdesign/core` is resolvable from the project |
408
+ | Version alignment | pass / warn / info | Installed `@astryxdesign/core` is in step with `@astryxdesign/cli` |
409
+ | Theme packages | pass / warn | An `@astryxdesign/theme-*` package is installed and a theme is wired |
410
+ | astryx.config.mjs | pass / fail / info | Config (if present) loads cleanly with a valid shape |
411
+ | AI agent docs | pass / warn / info | Agent docs exist and contain the XDS section markers |
412
+ | Peer dependencies | pass / warn / info | `@astryxdesign/core`'s peer deps (react, …) are installed |
413
+ | Package manager | info | Reports the detected package manager |
372
414
 
373
415
  ### CI gate
374
416
 
@@ -183,7 +183,7 @@ npx astryx docs tokens --dense`,
183
183
  "mcpServers": {
184
184
  "xds": {
185
185
  "type": "url",
186
- "url": "https://astryx.meta.com/mcp"
186
+ "url": "https://astryx.atmeta.com/mcp"
187
187
  }
188
188
  }
189
189
  }`,
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.d1e1201",
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.d1e1201",
58
+ "@astryxdesign/lab": "0.1.0-canary.d1e1201",
59
+ "@astryxdesign/theme-neutral": "0.1.0-canary.d1e1201"
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.d1e1201",
74
+ "@astryxdesign/lab": "0.1.0-canary.d1e1201",
75
+ "@astryxdesign/theme-neutral": "0.1.0-canary.d1e1201"
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