@astryxdesign/cli 0.1.0-canary.e2d38fb → 0.1.0-canary.eb78210

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.
@@ -24,21 +24,6 @@ import {search as searchApi} from '../api/search.mjs';
24
24
  const PAGE_DIRECT = 95;
25
25
  /** Below this a page is too weak to even offer as a layout reference. */
26
26
  const PAGE_FLOOR = 50;
27
- /** Below this a block/domain-component match is incidental noise, not surfaced. */
28
- const DOMAIN_FLOOR = 55;
29
-
30
- /**
31
- * Always-surfaced primitives. Every page needs a shell + layout/typography/
32
- * action atoms, but these never keyword-match an idea ("dashboard" != "Stack"),
33
- * so search alone never returns them. We list them unconditionally so an agent
34
- * composing from scratch has the whole kit (esp. off-template).
35
- */
36
- const FRAME = ['AppShell', 'TopNav', 'SideNav', 'Layout'];
37
- const FOUNDATION = [
38
- 'VStack', 'HStack', 'Grid', 'StackItem', 'Card', 'Section',
39
- 'Text', 'Heading', 'Button', 'Icon', 'Badge', 'Divider',
40
- ];
41
- const ALWAYS = new Set([...FRAME, ...FOUNDATION]);
42
27
 
43
28
  /** Print the build playbook (shown when `build` is run with no query). */
44
29
  function printPlaybook(run) {
@@ -120,17 +105,14 @@ export function registerBuild(program) {
120
105
  return;
121
106
  }
122
107
 
123
- // ── Group results by role (the build kit) ──────────────────────
108
+ // Group by role so an agent can assemble a UI from the pieces.
124
109
  const pages = results
125
110
  .filter(r => r.domain === 'template' && r.kind !== 'block' && r.score >= PAGE_FLOOR)
126
111
  .slice(0, 3);
127
- const blocks = results
128
- .filter(r => r.domain === 'template' && r.kind === 'block' && r.score >= DOMAIN_FLOOR)
112
+ const blocks = results.filter(r => r.domain === 'template' && r.kind === 'block').slice(0, 5);
113
+ const atoms = results
114
+ .filter(r => r.domain === 'component' || r.domain === 'hook')
129
115
  .slice(0, 5);
130
- // Idea-specific atoms = matched components/hooks MINUS the always-on kit.
131
- const domain = results
132
- .filter(r => (r.domain === 'component' || r.domain === 'hook') && r.score >= DOMAIN_FLOOR && !ALWAYS.has(r.name))
133
- .slice(0, 6);
134
116
  const directMatch = pages.length > 0 && pages[0].score >= PAGE_DIRECT;
135
117
 
136
118
  const printItem = (r, label) => {
@@ -148,49 +130,44 @@ export function registerBuild(program) {
148
130
  humanLog('');
149
131
  humanLog(`Building "${q}":`);
150
132
 
151
- // START the single recommended path.
152
- humanLog('');
153
- if (directMatch) {
154
- humanLog(`START → Scaffold the \`${pages[0].name}\` page template, then adapt: ${run} astryx template ${pages[0].name} ./src/App.tsx`);
155
- } else if (pages.length) {
156
- humanLog(`START → No exact page template. Use \`${pages[0].name}\` as a layout reference (${run} astryx template ${pages[0].name} --skeleton) and compose the pieces below.`);
157
- } else {
158
- humanLog(`START → No page template fits. Frame with AppShell and compose the blocks + components below.`);
159
- }
160
-
161
- // PAGE
133
+ let frame = null;
162
134
  if (pages.length) {
163
135
  humanLog('');
164
- humanLog(directMatch ? 'PAGE TEMPLATE — direct match:' : 'CLOSEST PAGE TEMPLATES — layout reference:');
136
+ humanLog(
137
+ directMatch
138
+ ? 'PAGE TEMPLATE — looks like a direct match (scaffold this):'
139
+ : 'CLOSEST PAGE TEMPLATES — scaffold if one fits, or use as a layout reference:',
140
+ );
165
141
  pages.forEach(p => printItem(p, directMatch ? 'page' : 'closest'));
142
+ humanLog(` (study structure: ${run} astryx template ${pages[0].name} --skeleton)`);
143
+ frame = pages[0];
144
+ } else {
145
+ humanLog('');
146
+ humanLog('NO MATCHING PAGE TEMPLATE — compose from the blocks + components below:');
166
147
  }
167
148
 
168
- // FRAME — always (the page shell).
169
- humanLog('');
170
- humanLog(`FRAME — page shell (always): ${FRAME.join(', ')}`);
171
- humanLog(` full-page → AppShell; or Layout + SideNav/TopNav. ${run} astryx component AppShell`);
172
-
173
- // BLOCKS — idea-specific composed patterns.
174
149
  if (blocks.length) {
175
150
  humanLog('');
176
- humanLog('BLOCKS — drop-in patterns that cover parts of it:');
151
+ humanLog('BLOCKS — drop-in patterns that likely cover parts of it:');
177
152
  blocks.forEach(b => printItem(b, 'block'));
178
153
  }
179
154
 
180
- // DOMAIN COMPONENTS — idea-specific atoms.
181
- if (domain.length) {
155
+ if (atoms.length) {
182
156
  humanLog('');
183
- humanLog('DOMAIN COMPONENTS — specific to this idea:');
184
- domain.forEach(c => printItem(c, c.domain === 'hook' ? 'hook' : 'component'));
157
+ humanLog('COMPONENTS — building blocks to fill the gaps:');
158
+ atoms.forEach(c => printItem(c, c.domain === 'hook' ? 'hook' : 'component'));
185
159
  }
186
160
 
187
- // FOUNDATION always (layout/typography/actions).
188
- humanLog('');
189
- humanLog(`FOUNDATION always available (layout/text/actions): ${FOUNDATION.join(' ')}`);
190
-
191
- // SETUP so it renders / stays on-system.
192
- humanLog('');
193
- humanLog('SETUP — import "@astryxdesign/core/reset.css" + "astryx.css". No <div>/style for layout — use Stack/Grid + tokens.');
161
+ const parts = [];
162
+ if (frame) {
163
+ parts.push(directMatch ? `scaffold \`${frame.name}\`` : `frame with \`${frame.name}\` (--skeleton)`);
164
+ }
165
+ if (blocks.length) parts.push(`drop in ${blocks.slice(0, 2).map(b => '`' + b.name + '`').join(', ')}`);
166
+ if (atoms.length) parts.push(`fill with ${atoms.slice(0, 2).map(c => '`' + c.name + '`').join(', ')}`);
167
+ if (parts.length) {
168
+ humanLog('');
169
+ humanLog('Compose: ' + parts.join(' → '));
170
+ }
194
171
  humanLog('');
195
172
  });
196
173
  }
@@ -100,15 +100,7 @@ async function runTemplate(targetDir, {interactive = true, templateName} = {}) {
100
100
 
101
101
  if (!interactive) {
102
102
  if (!templateName) {
103
- // Point agents at the build workflow rather than dumping page-template
104
- // names — `build` surfaces pages AND blocks AND components for an idea,
105
- // and `build` with no args is the full how-to-build playbook.
106
- humanLog('✓ To build UI, use these commands:');
107
- humanLog('');
108
- humanLog(` ${run} astryx build "<what you're building>" build a page — kit: closest template + blocks + components`);
109
- humanLog(` ${run} astryx build the how-to-build workflow (read this first)`);
110
- humanLog(` ${run} astryx search <query> find anything — components, docs, templates, blocks`);
111
- humanLog('');
103
+ humanLog(`✓ Available templates: ${templates.join(', ')}. Use ${run} astryx template <name> [path].`);
112
104
  return;
113
105
  }
114
106
 
@@ -31,13 +31,15 @@ import {execFile} from 'node:child_process';
31
31
  import {promisify} from 'node:util';
32
32
  import * as p from '@clack/prompts';
33
33
  import {ensureJscodeshift} from '../codemods/ensure-jscodeshift.mjs';
34
- import {getTransformsBetween, latestVersion} from '../codemods/registry.mjs';
34
+ import {
35
+ getTransformsBetween,
36
+ latestVersion,
37
+ } from '../codemods/registry.mjs';
35
38
  import {runCodemods} from '../codemods/runner.mjs';
36
39
  import {installAgentDocs, discoverAgentDocs} from './agent-docs.mjs';
37
40
  import {getRunPrefix} from '../utils/package-manager.mjs';
38
41
  import {isValidSemver, semverGte, semverGt} from '../utils/semver.mjs';
39
42
  import {jsonOut, jsonError} from '../lib/json.mjs';
40
- import {loadConfig} from '../lib/config.mjs';
41
43
  import {ERROR_CODES} from '../lib/error-codes.mjs';
42
44
 
43
45
  const execFileAsync = promisify(execFile);
@@ -64,6 +66,7 @@ function detectInstalledTargetVersion() {
64
66
  return null;
65
67
  }
66
68
 
69
+
67
70
  function isPathSpec(spec) {
68
71
  return (
69
72
  spec.startsWith('.') ||
@@ -144,9 +147,7 @@ function normalizeIntegrationTransforms(integration, from, to) {
144
147
  `Integration ${integration.name ?? integration.__spec} has a codemod without a name.`,
145
148
  );
146
149
  if (!entry.transform)
147
- throw new Error(
148
- `Integration codemod ${entry.name} is missing transform.`,
149
- );
150
+ throw new Error(`Integration codemod ${entry.name} is missing transform.`);
150
151
  const directTransform =
151
152
  typeof entry.transform === 'function' ? entry.transform : null;
152
153
  if (!directTransform)
@@ -156,9 +157,7 @@ function normalizeIntegrationTransforms(integration, from, to) {
156
157
  transforms.push({
157
158
  name: entry.name,
158
159
  meta: {
159
- title:
160
- entry.title ??
161
- `${integration.name ?? integration.__spec}: ${entry.name}`,
160
+ title: entry.title ?? `${integration.name ?? integration.__spec}: ${entry.name}`,
162
161
  description: entry.description ?? '',
163
162
  pr: entry.pr,
164
163
  fileExtensions: entry.fileExtensions,
@@ -180,7 +179,9 @@ async function runPostCodemodHooks(integrations, context, silent) {
180
179
  );
181
180
  if (hooks.length === 0) return;
182
181
 
183
- const log = silent ? {info() {}, warn() {}, success() {}, error() {}} : p.log;
182
+ const log = silent
183
+ ? {info() {}, warn() {}, success() {}, error() {}}
184
+ : p.log;
184
185
 
185
186
  const run = async (command, args, options = {}) => {
186
187
  await execFileAsync(command, args, {
@@ -208,9 +209,7 @@ async function runPostCodemodHooks(integrations, context, silent) {
208
209
  });
209
210
  }
210
211
  } else {
211
- log.warn(
212
- `Integration hook ${label} has no run() or command() function; skipping.`,
213
- );
212
+ log.warn(`Integration hook ${label} has no run() or command() function; skipping.`);
214
213
  continue;
215
214
  }
216
215
  log.success(`Post-codemod hook ${label} completed.`);
@@ -227,16 +226,9 @@ export function registerUpgrade(program) {
227
226
  program
228
227
  .command('upgrade')
229
228
  .description('Run codemods to migrate between versions')
230
- .option(
231
- '--from <version>',
232
- 'Previous version before the dependency upgrade',
233
- )
229
+ .option('--from <version>', 'Previous version before the dependency upgrade')
234
230
  .option('--apply', 'Write changes to disk (default: dry-run)', false)
235
- .option(
236
- '--force',
237
- 'Run codemods even if --from is newer than the installed version',
238
- false,
239
- )
231
+ .option('--force', 'Run codemods even if --from is newer than the installed version', false)
240
232
  .option('--codemod <name>', 'Run a specific transform only')
241
233
  .option(
242
234
  '--integration <package-or-file>',
@@ -245,21 +237,15 @@ export function registerUpgrade(program) {
245
237
  [],
246
238
  )
247
239
  .option('--path <dir>', 'Source directory to scan', './src')
248
- .option(
249
- '--install-deps',
250
- 'Auto-install jscodeshift without prompting',
251
- false,
252
- )
240
+ .option('--install-deps', 'Auto-install jscodeshift without prompting', false)
253
241
  .option('--list', 'List available codemods', false)
254
- .action(async options => {
242
+ .action(async (options) => {
255
243
  const json = program.opts().json || false;
256
244
  if (!json) p.intro('Upgrade');
257
245
 
258
246
  if (!options.list && !options.from) {
259
- const msg =
260
- 'Missing required --from. Install the target version first, then run `astryx upgrade --from <old-version>`.';
261
- if (json)
262
- return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
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);
263
249
  p.log.error(msg);
264
250
  p.outro('Aborted');
265
251
  process.exitCode = 1;
@@ -269,8 +255,7 @@ export function registerUpgrade(program) {
269
255
  // Validate --from upfront so callers don't silently accept typos.
270
256
  if (!options.list && !isValidSemver(options.from)) {
271
257
  const msg = `Invalid --from value: "${options.from}". Expected a semver string like 0.0.5.`;
272
- if (json)
273
- return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_VERSION);
258
+ if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_VERSION);
274
259
  p.log.error(msg);
275
260
  p.outro('Aborted');
276
261
  process.exitCode = 1;
@@ -285,30 +270,13 @@ export function registerUpgrade(program) {
285
270
  const manifests = await getTransformsBetween('0.0.0', latestVersion);
286
271
  for (const {version, transforms} of manifests) {
287
272
  for (const {name, meta, optional} of transforms) {
288
- codemods.push({
289
- name,
290
- title: meta.title,
291
- version,
292
- pr: meta.pr,
293
- optional: !!optional,
294
- });
273
+ codemods.push({name, title: meta.title, version, pr: meta.pr, optional: !!optional});
295
274
  }
296
275
  }
297
- if (json)
298
- return jsonOut(
299
- 'upgrade.list',
300
- codemods.map(({name, title, version, optional}) => ({
301
- name,
302
- title,
303
- version,
304
- optional,
305
- })),
306
- );
276
+ if (json) return jsonOut('upgrade.list', codemods.map(({name, title, version, optional}) => ({name, title, version, optional})));
307
277
  p.log.step('Available codemods:');
308
278
  for (const {name, title, pr, optional} of codemods) {
309
- p.log.info(
310
- ` ${name} — ${title}${optional ? ' (optional)' : ''} (${pr})`,
311
- );
279
+ p.log.info(` ${name} — ${title}${optional ? ' (optional)' : ''} (${pr})`);
312
280
  }
313
281
  p.outro('Done');
314
282
  return;
@@ -317,10 +285,8 @@ export function registerUpgrade(program) {
317
285
  const currentVersion = options.from;
318
286
  const installed = detectInstalledTargetVersion();
319
287
  if (!installed) {
320
- const msg =
321
- 'Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun `astryx upgrade --from <old-version>`.';
322
- if (json)
323
- return jsonError(msg, undefined, ERROR_CODES.ERR_VERSION_DETECT);
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>`.';
289
+ if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_VERSION_DETECT);
324
290
  p.log.error(msg);
325
291
  p.outro('Aborted');
326
292
  process.exitCode = 1;
@@ -330,26 +296,14 @@ export function registerUpgrade(program) {
330
296
 
331
297
  if (!json) {
332
298
  p.log.info(`From version: ${currentVersion}`);
333
- p.log.info(
334
- `Installed target: ${targetVersion} (${installed.packageName})`,
335
- );
299
+ p.log.info(`Installed target: ${targetVersion} (${installed.packageName})`);
336
300
  }
337
301
 
338
302
  let integrations;
339
303
  try {
340
- const config = await loadConfig(process.cwd());
341
- const integrationSpecs = uniqueFiles([
342
- ...(config.integrations ?? []),
343
- ...(options.integration ?? []),
344
- ]);
345
- integrations = await loadIntegrations(integrationSpecs);
304
+ integrations = await loadIntegrations(options.integration ?? []);
346
305
  } catch (err) {
347
- if (json)
348
- return jsonError(
349
- err.message,
350
- undefined,
351
- ERROR_CODES.ERR_INVALID_ARGUMENT,
352
- );
306
+ if (json) return jsonError(err.message, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
353
307
  p.log.error(err.message);
354
308
  p.outro('Aborted');
355
309
  process.exitCode = 1;
@@ -379,11 +333,7 @@ export function registerUpgrade(program) {
379
333
  const versionManifests = [
380
334
  ...(await getTransformsBetween(currentVersion, targetVersion)),
381
335
  ...integrations.flatMap(integration =>
382
- normalizeIntegrationTransforms(
383
- integration,
384
- currentVersion,
385
- targetVersion,
386
- ),
336
+ normalizeIntegrationTransforms(integration, currentVersion, targetVersion),
387
337
  ),
388
338
  ];
389
339
 
@@ -416,8 +366,7 @@ export function registerUpgrade(program) {
416
366
 
417
367
  if (totalTransforms === 0 && totalOptional === 0) {
418
368
  const msg = `Codemod "${options.codemod}" not found. Use --list to see available codemods.`;
419
- if (json)
420
- return jsonError(msg, undefined, ERROR_CODES.ERR_UNKNOWN_CODEMOD);
369
+ if (json) return jsonError(msg, undefined, ERROR_CODES.ERR_UNKNOWN_CODEMOD);
421
370
  p.log.error(msg);
422
371
  p.outro('Aborted');
423
372
  process.exitCode = 1;
@@ -434,26 +383,12 @@ export function registerUpgrade(program) {
434
383
  }
435
384
  }
436
385
 
437
- const receipt = {
438
- from: currentVersion,
439
- to: targetVersion,
440
- codemods: totalTransforms,
441
- integrations: integrations.map(i => i.name ?? i.__spec),
442
- agentDocsRefreshed: false,
443
- };
386
+ const receipt = {from: currentVersion, to: targetVersion, codemods: totalTransforms, integrations: integrations.map(i => i.name ?? i.__spec), agentDocsRefreshed: false};
444
387
 
445
388
  // Ensure jscodeshift is available
446
- const ready = await ensureJscodeshift({
447
- installDeps: options.installDeps,
448
- silent: json,
449
- });
389
+ const ready = await ensureJscodeshift({installDeps: options.installDeps, silent: json});
450
390
  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
- );
391
+ if (json) return jsonError('jscodeshift is required but could not be installed.', undefined, ERROR_CODES.ERR_DEP_MISSING);
457
392
  p.outro('Aborted');
458
393
  process.exitCode = 1;
459
394
  return;
@@ -469,9 +404,7 @@ export function registerUpgrade(program) {
469
404
 
470
405
  if (options.apply && integrations.length > 0) {
471
406
  const codemodDir = path.resolve(options.path);
472
- const absoluteChangedFiles = uniqueFiles(
473
- codemodResult?.writtenFiles ?? [],
474
- );
407
+ const absoluteChangedFiles = uniqueFiles(codemodResult?.writtenFiles ?? []);
475
408
  const changedFiles = absoluteChangedFiles.map(file =>
476
409
  path.relative(process.cwd(), file),
477
410
  );
@@ -502,8 +435,7 @@ export function registerUpgrade(program) {
502
435
  // Don't inject into files that never had Astryx content.
503
436
  const written = installAgentDocs(process.cwd(), {onlyReplace: true});
504
437
  receipt.agentDocsRefreshed = written.length > 0;
505
- if (!json && written.length > 0)
506
- p.log.success(`Agent docs updated: ${written.join(', ')}`);
438
+ if (!json && written.length > 0) p.log.success(`Agent docs updated: ${written.join(', ')}`);
507
439
  } catch {
508
440
  if (!json) {
509
441
  p.log.warn(
@@ -513,23 +445,12 @@ export function registerUpgrade(program) {
513
445
  }
514
446
  }
515
447
 
516
- if (codemodResult && typeof codemodResult === 'object') {
517
- receipt.filesChanged = codemodResult.totalFilesChanged ?? 0;
518
- receipt.transformsApplied = codemodResult.totalTransformsApplied ?? 0;
519
- receipt.errors = codemodResult.errors ?? [];
520
- }
521
-
522
- if (receipt.errors?.length > 0) {
523
- const msg = `Upgrade completed with ${receipt.errors.length} codemod error${receipt.errors.length === 1 ? '' : 's'}.`;
524
- if (json) {
525
- return jsonError(msg, {receipt}, ERROR_CODES.ERR_CODEMOD_FAILED);
526
- }
527
- p.outro('Upgrade failed');
528
- process.exitCode = 1;
529
- return;
530
- }
531
-
532
448
  if (json) {
449
+ if (codemodResult && typeof codemodResult === 'object') {
450
+ receipt.filesChanged = codemodResult.totalFilesChanged ?? 0;
451
+ receipt.transformsApplied = codemodResult.totalTransformsApplied ?? 0;
452
+ receipt.errors = codemodResult.errors ?? [];
453
+ }
533
454
  return jsonOut('upgrade.run', receipt);
534
455
  }
535
456
  p.outro(options.apply ? 'Upgrade complete' : 'Dry run complete');
@@ -13,7 +13,6 @@ import {pathToFileURL} from 'node:url';
13
13
 
14
14
  const DEFAULTS = {
15
15
  packages: [],
16
- integrations: [],
17
16
  };
18
17
 
19
18
  /**
@@ -47,7 +46,6 @@ export async function loadConfig(startDir = process.cwd()) {
47
46
  ...DEFAULTS,
48
47
  ...config,
49
48
  packages: normalizePackages(config.packages, path.dirname(configPath)),
50
- integrations: normalizeIntegrations(config.integrations),
51
49
  };
52
50
  } catch {
53
51
  return {...DEFAULTS};
@@ -74,13 +72,3 @@ function normalizePackages(packages, configDir) {
74
72
  }
75
73
  return result;
76
74
  }
77
-
78
- /**
79
- * Normalize integration specs to a string array. Integrations are package names
80
- * or manifest paths consumed by `astryx upgrade --integration`.
81
- */
82
- function normalizeIntegrations(integrations) {
83
- if (!integrations) return [];
84
- const arr = Array.isArray(integrations) ? integrations : [integrations];
85
- return arr.filter(value => typeof value === 'string' && value !== '');
86
- }
@@ -56,7 +56,6 @@
56
56
  * | 'ERR_UNKNOWN_AGENT'
57
57
  * | 'ERR_UNKNOWN_FEATURE'
58
58
  * | 'ERR_UNKNOWN_CODEMOD'
59
- | 'ERR_CODEMOD_FAILED'
60
59
  * | 'ERR_NOT_FOUND'
61
60
  * | 'ERR_NO_DOC'
62
61
  * | 'ERR_NO_SHOWCASE'
@@ -130,8 +129,6 @@ export const ERROR_CODES = Object.freeze({
130
129
  ERR_UNKNOWN_FEATURE: 'ERR_UNKNOWN_FEATURE',
131
130
  /** A `--codemod` value did not match any registered codemod (upgrade). */
132
131
  ERR_UNKNOWN_CODEMOD: 'ERR_UNKNOWN_CODEMOD',
133
- /** One or more codemods failed during an upgrade run. */
134
- ERR_CODEMOD_FAILED: 'ERR_CODEMOD_FAILED',
135
132
  /** A generic discover/lookup query matched nothing in any package. */
136
133
  ERR_NOT_FOUND: 'ERR_NOT_FOUND',
137
134
 
@@ -29,7 +29,6 @@ export type ErrorCode =
29
29
  | 'ERR_UNKNOWN_AGENT'
30
30
  | 'ERR_UNKNOWN_FEATURE'
31
31
  | 'ERR_UNKNOWN_CODEMOD'
32
- | 'ERR_CODEMOD_FAILED'
33
32
  | 'ERR_NOT_FOUND'
34
33
  | 'ERR_NO_DOC'
35
34
  | 'ERR_NO_SHOWCASE'
@@ -4,8 +4,9 @@
4
4
  * @file Check for newer @astryxdesign/core versions via local signals.
5
5
  *
6
6
  * Resolution order:
7
- * 1. $ASTRYX_LATEST_VERSION env var
8
- * 2. If unset: no hint (no network calls from this module)
7
+ * 1. $ASTRYX_LATEST_VERSION env var (set by previous CLI invocation)
8
+ * 2. xds.versionFile in consumer's package.json (e.g. ../../libs/xds-common/LATEST_VERSION)
9
+ * 3. If neither: no hint (no network calls from this module)
9
10
  *
10
11
  * When a newer version is detected, returns a hint string for CLI output.
11
12
  * Also sets $ASTRYX_LATEST_VERSION for subsequent commands in the same shell.
@@ -22,13 +23,34 @@ import {semverGt} from './semver.mjs';
22
23
  * @param {string} [cwd] - Project directory (default: process.cwd())
23
24
  * @returns {string|null} Latest version string, or null if unknown
24
25
  */
25
- export function getLatestVersion() {
26
+ export function getLatestVersion(cwd = process.cwd()) {
26
27
  // 1. Check env var (fastest — set by previous CLI run)
27
28
  const envVersion = process.env.ASTRYX_LATEST_VERSION;
28
29
  if (envVersion && /^\d+\.\d+\.\d+/.test(envVersion)) {
29
30
  return envVersion;
30
31
  }
31
32
 
33
+ // 2. Check versionFile from package.json config
34
+ try {
35
+ const pkgPath = path.resolve(cwd, 'package.json');
36
+ if (fs.existsSync(pkgPath)) {
37
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
38
+ const versionFile = pkg.astryx?.versionFile;
39
+
40
+ if (versionFile) {
41
+ const filePath = path.resolve(cwd, versionFile);
42
+ if (fs.existsSync(filePath)) {
43
+ const version = fs.readFileSync(filePath, 'utf-8').trim();
44
+ if (/^\d+\.\d+\.\d+/.test(version)) {
45
+ return version;
46
+ }
47
+ }
48
+ }
49
+ }
50
+ } catch {
51
+ // Silently fail — version check should never break the CLI
52
+ }
53
+
32
54
  return null;
33
55
  }
34
56
 
@@ -75,7 +97,7 @@ export function checkForUpdate(cwd = process.cwd()) {
75
97
  // Use semver-aware comparison so '0.0.20' is correctly treated as greater
76
98
  // than '0.0.5' (lexicographic compare gets that backwards).
77
99
  if (semverGt(latest, installed)) {
78
- return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: astryx upgrade --from <old-version> --apply`;
100
+ return `FYI: A newer version of @astryxdesign/core (${latest}) is available. You can upgrade with: astryx upgrade --apply --to ${latest}`;
79
101
  }
80
102
 
81
103
  return null;
@@ -41,6 +41,17 @@ describe('getLatestVersion', () => {
41
41
  expect(getLatestVersion(tmpDir)).toBeNull();
42
42
  });
43
43
 
44
+ it('reads from versionFile in package.json', () => {
45
+ const versionFilePath = path.join(tmpDir, 'LATEST_VERSION');
46
+ fs.writeFileSync(versionFilePath, '0.0.9\n');
47
+ fs.writeFileSync(
48
+ path.join(tmpDir, 'package.json'),
49
+ JSON.stringify({astryx: {versionFile: './LATEST_VERSION'}}),
50
+ );
51
+
52
+ expect(getLatestVersion(tmpDir)).toBe('0.0.9');
53
+ });
54
+
44
55
  it('returns null when no signals exist', () => {
45
56
  fs.writeFileSync(
46
57
  path.join(tmpDir, 'package.json'),
@@ -48,6 +59,26 @@ describe('getLatestVersion', () => {
48
59
  );
49
60
  expect(getLatestVersion(tmpDir)).toBeNull();
50
61
  });
62
+
63
+ it('returns null when versionFile path does not exist', () => {
64
+ fs.writeFileSync(
65
+ path.join(tmpDir, 'package.json'),
66
+ JSON.stringify({astryx: {versionFile: './missing/LATEST_VERSION'}}),
67
+ );
68
+ expect(getLatestVersion(tmpDir)).toBeNull();
69
+ });
70
+
71
+ it('env var takes priority over versionFile', () => {
72
+ process.env.ASTRYX_LATEST_VERSION = '1.0.0';
73
+ const versionFilePath = path.join(tmpDir, 'LATEST_VERSION');
74
+ fs.writeFileSync(versionFilePath, '0.0.9\n');
75
+ fs.writeFileSync(
76
+ path.join(tmpDir, 'package.json'),
77
+ JSON.stringify({astryx: {versionFile: './LATEST_VERSION'}}),
78
+ );
79
+
80
+ expect(getLatestVersion(tmpDir)).toBe('1.0.0');
81
+ });
51
82
  });
52
83
 
53
84
  describe('getInstalledVersion', () => {
@@ -86,7 +117,7 @@ describe('checkForUpdate', () => {
86
117
 
87
118
  const hint = checkForUpdate(tmpDir);
88
119
  expect(hint).toContain('0.0.8');
89
- expect(hint).toContain('astryx upgrade --from <old-version> --apply');
120
+ expect(hint).toContain('astryx upgrade --apply --to 0.0.8');
90
121
  expect(hint).toContain('FYI');
91
122
  });
92
123
 
@@ -109,6 +140,37 @@ describe('checkForUpdate', () => {
109
140
  expect(checkForUpdate(tmpDir)).toBeNull();
110
141
  });
111
142
 
143
+ it('sets env var for subsequent calls', () => {
144
+ const versionFilePath = path.join(tmpDir, 'LATEST_VERSION');
145
+ fs.writeFileSync(versionFilePath, '0.0.8\n');
146
+ fs.writeFileSync(
147
+ path.join(tmpDir, 'package.json'),
148
+ JSON.stringify({
149
+ dependencies: {'@astryxdesign/core': '^0.0.7'},
150
+ astryx: {versionFile: './LATEST_VERSION'},
151
+ }),
152
+ );
153
+
154
+ checkForUpdate(tmpDir);
155
+ expect(process.env.ASTRYX_LATEST_VERSION).toBe('0.0.8');
156
+ });
157
+
158
+ it('reads from versionFile and produces hint', () => {
159
+ const versionFilePath = path.join(tmpDir, 'LATEST_VERSION');
160
+ fs.writeFileSync(versionFilePath, '0.0.9\n');
161
+ fs.writeFileSync(
162
+ path.join(tmpDir, 'package.json'),
163
+ JSON.stringify({
164
+ dependencies: {'@astryxdesign/core': '^0.0.7'},
165
+ astryx: {versionFile: './LATEST_VERSION'},
166
+ }),
167
+ );
168
+
169
+ const hint = checkForUpdate(tmpDir);
170
+ expect(hint).toContain('0.0.9');
171
+ expect(hint).toContain('FYI');
172
+ });
173
+
112
174
  it('uses semver (not lexicographic) comparison for double-digit patches', () => {
113
175
  // Regression: with string compare, '0.0.20' > '0.0.5' is false because
114
176
  // '2' < '5' lexicographically. Users on 0.0.5 would never be told that