@vodmal/vdx-cli 0.19.0 → 0.20.0

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
@@ -42,7 +42,7 @@ vdx audit [project-path] [--format=ansi|markdown|json] [--rubric <path>] [--stac
42
42
  vdx init [project-path] [--baseline github.com/org/repo@vX.Y] [--stack <id>] [--dry-run] [--force] # default: cwd
43
43
 
44
44
  # Publish a library (Node MVP; PHP/Python coming in Y.3)
45
- vdx publish <patch|minor|major> [--dry-run] [--force]
45
+ vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
46
46
 
47
47
  # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin)
48
48
  vdx doctor [--format=ansi|markdown|json]
@@ -72,7 +72,7 @@ vdx init --stack node
72
72
  vdx test # → mise run test (which calls `vitest run` or whatever was detected)
73
73
 
74
74
  # 3. Ship a new minor release of a Node lib
75
- vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no push)
75
+ vdx publish minor # npm login if expired → bump package.json + lock → npm publish (OTP) → commit + tag → push → wait for the registry
76
76
 
77
77
  # 4. Start the agent here, continuing its last conversation (--new: a new one;
78
78
  # another machine's conversation is continued on that machine);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
package/src/index.ts CHANGED
@@ -23,8 +23,10 @@ import { runDoctor, looksLikeProject, readCliVersion } from './doctor.ts';
23
23
  import { planInit, writeInit, renderPlanSummary } from './init.ts';
24
24
  import {
25
25
  planPublish,
26
+ preflightFixable,
26
27
  renderPublishPlan,
27
28
  executePublish,
29
+ defaultPublishDeps,
28
30
  type BumpKind,
29
31
  type PublishOptions,
30
32
  } from './publish.ts';
@@ -38,7 +40,7 @@ const USAGE = `Usage:
38
40
  vdx <up|down|build|test|check|fix> run lifecycle verb (via mise run <verb>)
39
41
  vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json] (default: cwd)
40
42
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
41
- vdx publish <patch|minor|major> [--dry-run] [--force]
43
+ vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
42
44
  vdx doctor [--format=ansi|markdown|json] [--json]
43
45
  vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
44
46
  vdx <command> --help what the command does; an unknown option is refused (exit 2)
@@ -119,12 +121,18 @@ mise.toml is overwritten only with --force.
119
121
  positionals: 1,
120
122
  },
121
123
  publish: {
122
- usage: 'vdx publish <patch|minor|major> [--dry-run] [--force]',
123
- help: `Releases the library in the current directory: pre-flight checks, version bump,
124
- npm publish (npm asks for an OTP), git commit and tag. Does not push.
125
- --dry-run runs the pre-flight and prints the plan; --force goes on past a failed pre-flight.
124
+ usage: 'vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]',
125
+ help: `Releases the library in the current directory, start to finish:
126
+ pre-flight checks — at a terminal an expired npm login is renewed (\`npm login\`)
127
+ and the checks run again;
128
+ the version bump in package.json and package-lock.json;
129
+ npm publish (npm asks for an OTP);
130
+ git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
131
+ a wait until the registry shows the new version (up to 3 min).
132
+ --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
133
+ pre-flight; --no-push leaves the commit and the tag local.
126
134
  `,
127
- bools: ['dry-run', 'force'],
135
+ bools: ['dry-run', 'force', 'no-push'],
128
136
  positionals: 1,
129
137
  },
130
138
  doctor: {
@@ -320,7 +328,7 @@ function cmdPublish(opts: ParsedArgs): void {
320
328
  const bumpArg = opts.positionals[0];
321
329
  if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
322
330
  process.stderr.write(
323
- 'Usage: vdx publish <patch|minor|major> [--dry-run] [--force]\n',
331
+ 'Usage: vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]\n',
324
332
  );
325
333
  process.exit(1);
326
334
  }
@@ -340,15 +348,18 @@ function cmdPublish(opts: ParsedArgs): void {
340
348
  bump,
341
349
  dryRun: Boolean(opts.flags['dry-run']),
342
350
  force: Boolean(opts.flags.force),
351
+ push: !opts.flags['no-push'],
343
352
  };
344
353
 
345
- let plan;
346
- try {
347
- plan = planPublish(planOpts, auditResult, ctx);
348
- } catch (e: any) {
349
- process.stderr.write(`error: ${e?.message ?? String(e)}\n`);
350
- process.exit(2);
351
- }
354
+ const replan = () => {
355
+ try {
356
+ return planPublish(planOpts, auditResult, ctx);
357
+ } catch (e: any) {
358
+ process.stderr.write(`error: ${e?.message ?? String(e)}\n`);
359
+ process.exit(2);
360
+ }
361
+ };
362
+ let plan = replan();
352
363
 
353
364
  process.stdout.write(renderPublishPlan(plan));
354
365
 
@@ -357,13 +368,30 @@ function cmdPublish(opts: ParsedArgs): void {
357
368
  return;
358
369
  }
359
370
 
371
+ // A failure a command fixes (an expired npm login): run it here, then check again.
372
+ const deps = defaultPublishDeps();
373
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
374
+ if (!plan.preflightPassed && !planOpts.force && interactive && preflightFixable(plan)) {
375
+ for (const c of plan.preflight.filter((c) => !c.ok && c.fix)) {
376
+ process.stderr.write(`\n→ ${c.name}: ${c.fix!.cmd} ${c.fix!.args.join(' ')}\n`);
377
+ try {
378
+ deps.run(c.fix!.cmd, c.fix!.args, projectRoot);
379
+ } catch {
380
+ process.stderr.write(`error: \`${c.fix!.cmd} ${c.fix!.args.join(' ')}\` failed\n`);
381
+ process.exit(2);
382
+ }
383
+ }
384
+ plan = replan();
385
+ process.stdout.write('\n' + renderPublishPlan(plan));
386
+ }
387
+
360
388
  if (!plan.preflightPassed && !planOpts.force) {
361
389
  process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
362
390
  process.exit(2);
363
391
  }
364
392
 
365
393
  try {
366
- executePublish(plan);
394
+ executePublish(plan, deps);
367
395
  } catch (e: any) {
368
396
  process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
369
397
  process.exit(3);
package/src/publish.ts CHANGED
@@ -11,12 +11,28 @@ export interface PublishOptions {
11
11
  bump: BumpKind;
12
12
  dryRun: boolean;
13
13
  force: boolean;
14
+ /** `git push --follow-tags` after the tag; `--no-push` turns it off. */
15
+ push?: boolean;
14
16
  }
15
17
 
16
18
  export interface PreflightCheck {
17
19
  name: string;
18
20
  ok: boolean;
19
21
  message: string;
22
+ /** A failed check a command at the terminal can fix — `npm login` for an expired session. */
23
+ fix?: { cmd: string; args: string[] };
24
+ }
25
+
26
+ /** Where the release goes after the tag. */
27
+ export interface PushPlan {
28
+ /** Asked for (not `--no-push`). */
29
+ enabled: boolean;
30
+ /** `origin/main`; null — the branch has none, nothing to push to. */
31
+ upstream: string | null;
32
+ /** Local commits not on the upstream yet — they go with the release. */
33
+ ahead: number;
34
+ /** Upstream commits not here: the push would be refused. */
35
+ behind: number;
20
36
  }
21
37
 
22
38
  export interface PublishPlan {
@@ -27,6 +43,9 @@ export interface PublishPlan {
27
43
  newVersion: string;
28
44
  bump: BumpKind;
29
45
  packageJsonPath: string;
46
+ /** package-lock.json beside package.json, when there is one: its version goes into the same commit. */
47
+ lockPath?: string | null;
48
+ push?: PushPlan;
30
49
  preflight: PreflightCheck[];
31
50
  preflightPassed: boolean;
32
51
  delegatedToMise: boolean;
@@ -82,6 +101,33 @@ function isGitWorkingTreeClean(projectRoot: string): { ok: boolean; detail: stri
82
101
  /** Runs npm with these args and returns stdout; throws with `stderr` on a non-zero exit. */
83
102
  export type NpmRunner = (args: string[]) => string;
84
103
 
104
+ /** Runs git with these args in `cwd` and returns stdout; throws on a non-zero exit. */
105
+ export type GitRunner = (args: string[], cwd: string) => string;
106
+
107
+ const runGit: GitRunner = (args, cwd) =>
108
+ execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
109
+
110
+ /**
111
+ * The branch's upstream and how far apart they are — from what git knows
112
+ * since the last fetch; nothing is fetched.
113
+ */
114
+ export function planPush(projectRoot: string, enabled: boolean, git: GitRunner = runGit): PushPlan {
115
+ let upstream: string | null = null;
116
+ try {
117
+ upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], projectRoot).trim() || null;
118
+ } catch {
119
+ return { enabled, upstream: null, ahead: 0, behind: 0 };
120
+ }
121
+ const count = (range: string) => {
122
+ try {
123
+ return parseInt(git(['rev-list', '--count', range], projectRoot).trim(), 10) || 0;
124
+ } catch {
125
+ return 0;
126
+ }
127
+ };
128
+ return { enabled, upstream, ahead: count('@{u}..HEAD'), behind: count('HEAD..@{u}') };
129
+ }
130
+
85
131
  // A caller's `npm run -s` passes `npm_config_loglevel=silent` down, and npm then
86
132
  // fails with an empty stderr — no E401 or E404 left to tell the cases apart.
87
133
  const runNpm: NpmRunner = (args) =>
@@ -128,11 +174,13 @@ export function publishRegistry(
128
174
  */
129
175
  export function checkNpmAuth(registry: string | null, npm: NpmRunner): PreflightCheck {
130
176
  const where = registry ?? 'npm';
131
- const login = registry ? `npm login --registry ${registry}` : 'npm login';
177
+ const loginArgs = registry ? ['login', '--registry', registry] : ['login'];
178
+ const login = `npm ${loginArgs.join(' ')}`;
179
+ const fix = { cmd: 'npm', args: loginArgs };
132
180
  try {
133
181
  const who = npm(registry ? ['whoami', '--registry', registry] : ['whoami']).trim();
134
182
  if (who) return { name: 'npm-auth', ok: true, message: `OK (logged in to ${where} as ${who})` };
135
- return { name: 'npm-auth', ok: false, message: `npm whoami answered nothing for ${where}. Run: ${login}` };
183
+ return { name: 'npm-auth', ok: false, message: `npm whoami answered nothing for ${where}. Run: ${login}`, fix };
136
184
  } catch (e: any) {
137
185
  const stderr = String(e?.stderr ?? e?.message ?? '');
138
186
  if (/E401|ENEEDAUTH/.test(stderr)) {
@@ -140,6 +188,7 @@ export function checkNpmAuth(registry: string | null, npm: NpmRunner): Preflight
140
188
  name: 'npm-auth',
141
189
  ok: false,
142
190
  message: `not logged in to ${where} (the npm session may have expired). Run: ${login}`,
191
+ fix,
143
192
  };
144
193
  }
145
194
  return {
@@ -174,6 +223,7 @@ export function planPublish(
174
223
  audit: AuditResult,
175
224
  ctx: Ctx,
176
225
  npm: NpmRunner = runNpm,
226
+ git: GitRunner = runGit,
177
227
  ): PublishPlan {
178
228
  if (checkMiseOverride(opts.projectRoot)) {
179
229
  return {
@@ -288,6 +338,26 @@ export function planPublish(
288
338
  });
289
339
  }
290
340
 
341
+ const push = planPush(opts.projectRoot, opts.push ?? true, git);
342
+ if (push.enabled && push.upstream && push.behind > 0) {
343
+ preflight.push({
344
+ name: 'push-target',
345
+ ok: false,
346
+ message:
347
+ `${push.upstream} has ${push.behind} commit(s) this branch lacks — the push after npm publish ` +
348
+ `would be refused. Pull first (or --no-push)`,
349
+ });
350
+ } else if (push.enabled) {
351
+ preflight.push({
352
+ name: 'push-target',
353
+ ok: true,
354
+ message: push.upstream
355
+ ? `OK (${push.upstream}${push.ahead ? `, ${push.ahead} local commit(s) go with the release` : ''})`
356
+ : 'no upstream for this branch — the release commit and tag stay local',
357
+ });
358
+ }
359
+
360
+ const lockPath = path.join(path.dirname(pkgJsonPath), 'package-lock.json');
291
361
  const preflightPassed = preflight.every((c) => c.ok);
292
362
 
293
363
  return {
@@ -298,82 +368,165 @@ export function planPublish(
298
368
  newVersion,
299
369
  bump: opts.bump,
300
370
  packageJsonPath: pkgJsonPath,
371
+ lockPath: fs.existsSync(lockPath) ? lockPath : null,
372
+ push,
301
373
  preflight,
302
374
  preflightPassed,
303
375
  delegatedToMise: false,
304
376
  };
305
377
  }
306
378
 
379
+ /** Every failed check has a command that fixes it — run at a terminal, then plan again. */
380
+ export function preflightFixable(plan: PublishPlan): boolean {
381
+ const failed = plan.preflight.filter((c) => !c.ok);
382
+ return failed.length > 0 && failed.every((c) => c.fix);
383
+ }
384
+
385
+ /** Runs a command on this terminal (stdio inherited); throws on a non-zero exit. */
386
+ export type CommandRunner = (cmd: string, args: string[], cwd: string) => void;
387
+
388
+ export interface PublishDeps {
389
+ run: CommandRunner;
390
+ npm: NpmRunner;
391
+ log: (line: string) => void;
392
+ sleep: (ms: number) => void;
393
+ /** How long to wait for the registry to show the new version. */
394
+ registryWaitMs: number;
395
+ }
396
+
397
+ function sleepSync(ms: number): void {
398
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
399
+ }
400
+
401
+ export function defaultPublishDeps(): PublishDeps {
402
+ return {
403
+ run: (cmd, args, cwd) => {
404
+ execFileSync(cmd, args, { cwd, stdio: 'inherit' });
405
+ },
406
+ npm: runNpm,
407
+ log: (line) => process.stderr.write(line + '\n'),
408
+ sleep: sleepSync,
409
+ registryWaitMs: 180_000,
410
+ };
411
+ }
412
+
413
+ /** Set `version` in package-lock.json — the root and its own entry under `packages`. */
414
+ export function bumpLockText(text: string, version: string): string {
415
+ const lock = JSON.parse(text);
416
+ lock.version = version;
417
+ if (lock.packages?.['']) lock.packages[''].version = version;
418
+ return JSON.stringify(lock, null, 2) + '\n';
419
+ }
420
+
421
+ /**
422
+ * npm answers `202 Accepted` and the version shows up a minute or two later;
423
+ * until then `npm view` and `npx` still see the previous one. Waiting here
424
+ * turns "is it out?" into an answer. A version that does not show up in time
425
+ * is not a failure: npm took it.
426
+ */
427
+ export function waitForRegistry(name: string, version: string, deps: PublishDeps): boolean {
428
+ const started = Date.now();
429
+ const deadline = started + deps.registryWaitMs;
430
+ deps.log(`→ waiting for the registry to show ${name}@${version}...`);
431
+ for (;;) {
432
+ try {
433
+ if (deps.npm(['view', `${name}@${version}`, 'version', '--prefer-online']).trim() === version) {
434
+ deps.log(`✓ ${name}@${version} is on the registry (${Math.round((Date.now() - started) / 1000)} s after publish)`);
435
+ return true;
436
+ }
437
+ } catch {
438
+ /* E404 until it is processed */
439
+ }
440
+ if (Date.now() >= deadline) break;
441
+ deps.sleep(10_000);
442
+ }
443
+ deps.log(
444
+ `! ${name}@${version} not visible on the registry after ${Math.round(deps.registryWaitMs / 60_000)} min — ` +
445
+ `npm accepted it; check later: npm view ${name}@${version} version`,
446
+ );
447
+ return false;
448
+ }
449
+
307
450
  /**
308
451
  * Execute the publish pipeline. Order is transactional:
309
- * 1. bump package.json in-place (reversible by file restore).
452
+ * 1. bump package.json and package-lock.json in place (reversible by file restore).
310
453
  * 2. `npm publish` (irreversible — first because npm errors are easier
311
454
  * to recover from than a published-but-uncommitted state).
312
455
  * 3. git add + commit + tag (reversible by reset/tag delete, but only
313
456
  * a problem if step 2 succeeded — uncommitted bump after success
314
457
  * is recoverable manually).
315
- * 4. NOT pushed — that's the user's call.
458
+ * 4. `git push --follow-tags` to the branch's upstream, unless `--no-push`
459
+ * or there is none. The pre-flight refused a branch behind its upstream.
460
+ * 5. Wait until the registry shows the version.
316
461
  *
317
- * On npm publish failure → restore original package.json contents.
462
+ * On npm publish failure → restore the original files.
318
463
  * Git failures after a successful npm publish leave the user in a
319
- * partially-released state; they fix it manually (commit/tag what's
464
+ * partially-released state; they fix it manually (commit/tag/push what's
320
465
  * needed) — we don't try to unpublish.
321
466
  */
322
- export function executePublish(plan: PublishPlan): void {
467
+ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPublishDeps()): void {
323
468
  if (plan.delegatedToMise) {
324
- process.stderr.write('→ delegating to `mise run publish`...\n');
325
- execFileSync('mise', ['run', 'publish'], {
326
- cwd: plan.projectRoot,
327
- stdio: 'inherit',
328
- });
469
+ deps.log('→ delegating to `mise run publish`...');
470
+ deps.run('mise', ['run', 'publish'], plan.projectRoot);
329
471
  return;
330
472
  }
331
473
 
332
- // 1. Bump package.json in-place (keep original text for revert)
333
- const originalText = fs.readFileSync(plan.packageJsonPath, 'utf8');
334
- const pkg = JSON.parse(originalText);
335
- pkg.version = plan.newVersion;
336
- fs.writeFileSync(
337
- plan.packageJsonPath,
338
- JSON.stringify(pkg, null, 2) + '\n',
339
- 'utf8',
340
- );
341
- process.stderr.write(
342
- `✓ bumped ${path.relative(plan.projectRoot, plan.packageJsonPath)}: ${plan.currentVersion} → ${plan.newVersion}\n`,
474
+ // 1. Bump package.json (and the lock) in place, keeping the original text for revert.
475
+ const originals = new Map<string, string>();
476
+ const rewrite = (file: string, next: (text: string) => string) => {
477
+ const text = fs.readFileSync(file, 'utf8');
478
+ originals.set(file, text);
479
+ fs.writeFileSync(file, next(text), 'utf8');
480
+ };
481
+ rewrite(plan.packageJsonPath, (text) => {
482
+ const pkg = JSON.parse(text);
483
+ pkg.version = plan.newVersion;
484
+ return JSON.stringify(pkg, null, 2) + '\n';
485
+ });
486
+ if (plan.lockPath) rewrite(plan.lockPath, (text) => bumpLockText(text, plan.newVersion));
487
+ const bumped = [...originals.keys()];
488
+ deps.log(
489
+ `✓ bumped ${bumped.map((f) => path.relative(plan.projectRoot, f)).join(' and ')}: ${plan.currentVersion} → ${plan.newVersion}`,
343
490
  );
344
491
 
345
492
  // 2. npm publish (interactive — OTP prompt may appear)
346
- process.stderr.write(`→ running \`npm publish\` (OTP prompt may appear)...\n`);
347
- const pkgDir = path.dirname(plan.packageJsonPath);
493
+ deps.log('→ running `npm publish` (OTP prompt may appear)...');
348
494
  try {
349
- execFileSync('npm', ['publish'], { cwd: pkgDir, stdio: 'inherit' });
495
+ deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
350
496
  } catch (e: any) {
351
- fs.writeFileSync(plan.packageJsonPath, originalText, 'utf8');
352
- process.stderr.write(
353
- `✗ npm publish failed (exit ${e?.status ?? '?'}); reverted package.json\n`,
354
- );
497
+ for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
498
+ deps.log(`✗ npm publish failed (exit ${e?.status ?? '?'}); reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
355
499
  throw new Error('npm publish failed');
356
500
  }
357
- process.stderr.write(`✓ published ${plan.packageName}@${plan.newVersion}\n`);
501
+ deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
358
502
 
359
503
  // 3. git add + commit + tag (at project root)
360
- execFileSync('git', ['add', plan.packageJsonPath], {
361
- cwd: plan.projectRoot,
362
- stdio: 'inherit',
363
- });
364
- execFileSync('git', ['commit', '-m', `release: v${plan.newVersion}`], {
365
- cwd: plan.projectRoot,
366
- stdio: 'inherit',
367
- });
368
- execFileSync(
369
- 'git',
370
- ['tag', '-a', `v${plan.newVersion}`, '-m', `v${plan.newVersion}`],
371
- { cwd: plan.projectRoot, stdio: 'inherit' },
372
- );
373
- process.stderr.write(`✓ committed + tagged v${plan.newVersion}\n`);
374
- process.stderr.write(
375
- `\n! NOT pushed — your call: \`git push --follow-tags\`\n`,
376
- );
504
+ deps.run('git', ['add', '--', ...bumped], plan.projectRoot);
505
+ deps.run('git', ['commit', '-m', `release: v${plan.newVersion}`, '--', ...bumped], plan.projectRoot);
506
+ deps.run('git', ['tag', '-a', `v${plan.newVersion}`, '-m', `v${plan.newVersion}`], plan.projectRoot);
507
+ deps.log(`✓ committed + tagged v${plan.newVersion}`);
508
+
509
+ // 4. push
510
+ const push = plan.push;
511
+ if (push?.enabled && push.upstream) {
512
+ deps.log(`→ git push --follow-tags (${push.upstream})...`);
513
+ try {
514
+ deps.run('git', ['push', '--follow-tags'], plan.projectRoot);
515
+ } catch {
516
+ deps.log(`✗ git push failed — ${plan.packageName}@${plan.newVersion} is published and tagged here; push it: git push --follow-tags`);
517
+ throw new Error('git push failed');
518
+ }
519
+ deps.log(`✓ pushed to ${push.upstream} with tag v${plan.newVersion}`);
520
+ } else {
521
+ deps.log(
522
+ push?.enabled === false
523
+ ? '! not pushed (--no-push): git push --follow-tags'
524
+ : '! not pushed — the branch has no upstream: git push --follow-tags -u <remote> <branch>',
525
+ );
526
+ }
527
+
528
+ // 5. registry
529
+ waitForRegistry(plan.packageName, plan.newVersion, deps);
377
530
  }
378
531
 
379
532
  export function renderPublishPlan(plan: PublishPlan): string {
@@ -409,14 +562,28 @@ export function renderPublishPlan(plan: PublishPlan): string {
409
562
  lines.push(`| ${c.name} | ${c.ok ? 'OK' : 'FAIL'} | ${c.message} |`);
410
563
  }
411
564
  lines.push('');
412
- if (plan.preflightPassed) {
565
+ const fixable = !plan.preflightPassed && preflightFixable(plan);
566
+ if (plan.preflightPassed || fixable) {
567
+ const files = plan.lockPath ? 'package.json and package-lock.json' : 'package.json';
568
+ const push = plan.push;
413
569
  lines.push('## Pipeline (would execute on `vdx publish`)');
414
570
  lines.push('');
415
- lines.push(`1. Set \`version\` in package.json → \`${plan.newVersion}\``);
571
+ if (fixable) {
572
+ const fixes = plan.preflight.filter((c) => !c.ok && c.fix).map((c) => `\`${c.fix!.cmd} ${c.fix!.args.join(' ')}\``);
573
+ lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
574
+ }
575
+ lines.push(`1. Set \`version\` in ${files} → \`${plan.newVersion}\``);
416
576
  lines.push(`2. \`npm publish\` (cwd = directory containing package.json)`);
417
- lines.push(`3. \`git add package.json && git commit -m "release: v${plan.newVersion}"\``);
577
+ lines.push(`3. \`git commit -m "release: v${plan.newVersion}"\` (${files})`);
418
578
  lines.push(`4. \`git tag -a v${plan.newVersion} -m "v${plan.newVersion}"\``);
419
- lines.push(`5. (NOT pushed — your call: \`git push --follow-tags\`)`);
579
+ lines.push(
580
+ !push?.enabled
581
+ ? '5. Not pushed (`--no-push`): `git push --follow-tags` is yours'
582
+ : push.upstream
583
+ ? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
584
+ : '5. Not pushed: the branch has no upstream',
585
+ );
586
+ lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 3 min)`);
420
587
  } else {
421
588
  lines.push('> Pre-flight failed. Use `--force` to bypass at your own risk.');
422
589
  }