@vodmal/vdx-cli 0.19.0 → 0.20.1

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.1",
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/ai.ts CHANGED
@@ -957,6 +957,8 @@ export interface ConversationChoice {
957
957
  remote?: { host: string; id: string; reachable: boolean };
958
958
  /** Continue it here only after a yes: another machine's that cannot be asked, or one running here already. */
959
959
  askHere?: { id: string; why: string };
960
+ /** The person ended the question (Ctrl-D): start nothing. */
961
+ abort?: boolean;
960
962
  /** For the person: what was found and what is taken. */
961
963
  lines: string[];
962
964
  }
@@ -1047,7 +1049,8 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
1047
1049
  deps.log(' n) a new conversation');
1048
1050
  for (let tries = 0; tries < 2; tries++) {
1049
1051
  const answer = deps.ask(`Which one to continue? [1]: `);
1050
- if (answer === null) break;
1052
+ // Ctrl-D at the question is a cancel, not "no terminal": start nothing.
1053
+ if (answer === null) return { args: [], abort: true, lines: ['no answer — nothing started'] };
1051
1054
  const a = answer.trim();
1052
1055
  if (/^n(ew)?$/i.test(a)) return { args: [], lines: ['a new conversation'] };
1053
1056
  const c = a === '' ? found[0] : /^\d+$/.test(a) ? found[Number(a) - 1] : undefined;
@@ -1244,6 +1247,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1244
1247
  return EXIT_OK;
1245
1248
  }
1246
1249
  for (const l of choice?.lines ?? []) log(l);
1250
+ if (choice?.abort) return EXIT_LAUNCH_FAILED;
1247
1251
 
1248
1252
  let askHere = choice?.askHere;
1249
1253
  if (choice?.remote) {
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,187 @@ 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
+ /** Rewrites one status line in place (a terminal); without it the wait logs a line now and then. */
396
+ progress?: (text: string) => void;
397
+ }
398
+
399
+ function sleepSync(ms: number): void {
400
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
401
+ }
402
+
403
+ export function defaultPublishDeps(): PublishDeps {
404
+ return {
405
+ run: (cmd, args, cwd) => {
406
+ execFileSync(cmd, args, { cwd, stdio: 'inherit' });
407
+ },
408
+ npm: runNpm,
409
+ log: (line) => process.stderr.write(line + '\n'),
410
+ sleep: sleepSync,
411
+ registryWaitMs: 180_000,
412
+ progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
413
+ };
414
+ }
415
+
416
+ /** Set `version` in package-lock.json — the root and its own entry under `packages`. */
417
+ export function bumpLockText(text: string, version: string): string {
418
+ const lock = JSON.parse(text);
419
+ lock.version = version;
420
+ if (lock.packages?.['']) lock.packages[''].version = version;
421
+ return JSON.stringify(lock, null, 2) + '\n';
422
+ }
423
+
424
+ /**
425
+ * npm answers `202 Accepted` and the version shows up a minute or two later;
426
+ * until then `npm view` and `npx` still see the previous one. Waiting here
427
+ * turns "is it out?" into an answer. A version that does not show up in time
428
+ * is not a failure: npm took it.
429
+ */
430
+ export function waitForRegistry(name: string, version: string, deps: PublishDeps): boolean {
431
+ const started = Date.now();
432
+ const deadline = started + deps.registryWaitMs;
433
+ const limit = Math.round(deps.registryWaitMs / 1000);
434
+ const seconds = () => Math.round((Date.now() - started) / 1000);
435
+ deps.log(
436
+ `→ waiting for the registry to show ${name}@${version} — npm has taken it; ` +
437
+ `the registry usually shows it within ~2 min (giving it ${limit} s)`,
438
+ );
439
+ const shown = () => {
440
+ try {
441
+ return deps.npm(['view', `${name}@${version}`, 'version', '--prefer-online']).trim() === version;
442
+ } catch {
443
+ return false; // E404 until it is processed
444
+ }
445
+ };
446
+ // Ask npm every 10 s; between the asks, count the seconds on one line.
447
+ let lastLogged = 0;
448
+ for (let found = shown(); ; found = shown()) {
449
+ if (found) {
450
+ deps.progress?.('');
451
+ deps.log(`✓ ${name}@${version} is on the registry (${seconds()} s after publish)`);
452
+ return true;
453
+ }
454
+ if (Date.now() >= deadline) break;
455
+ for (let i = 0; i < 10 && Date.now() < deadline; i++) {
456
+ if (deps.progress) deps.progress(` … ${seconds()} s of ${limit} s`);
457
+ else if (seconds() - lastLogged >= 30) {
458
+ lastLogged = seconds();
459
+ deps.log(` … ${lastLogged} s`);
460
+ }
461
+ deps.sleep(1_000);
462
+ }
463
+ }
464
+ deps.progress?.('');
465
+ deps.log(
466
+ `! ${name}@${version} not visible on the registry after ${Math.round(deps.registryWaitMs / 60_000)} min — ` +
467
+ `npm accepted it; check later: npm view ${name}@${version} version`,
468
+ );
469
+ return false;
470
+ }
471
+
307
472
  /**
308
473
  * Execute the publish pipeline. Order is transactional:
309
- * 1. bump package.json in-place (reversible by file restore).
474
+ * 1. bump package.json and package-lock.json in place (reversible by file restore).
310
475
  * 2. `npm publish` (irreversible — first because npm errors are easier
311
476
  * to recover from than a published-but-uncommitted state).
312
477
  * 3. git add + commit + tag (reversible by reset/tag delete, but only
313
478
  * a problem if step 2 succeeded — uncommitted bump after success
314
479
  * is recoverable manually).
315
- * 4. NOT pushed — that's the user's call.
480
+ * 4. `git push --follow-tags` to the branch's upstream, unless `--no-push`
481
+ * or there is none. The pre-flight refused a branch behind its upstream.
482
+ * 5. Wait until the registry shows the version.
316
483
  *
317
- * On npm publish failure → restore original package.json contents.
484
+ * On npm publish failure → restore the original files.
318
485
  * Git failures after a successful npm publish leave the user in a
319
- * partially-released state; they fix it manually (commit/tag what's
486
+ * partially-released state; they fix it manually (commit/tag/push what's
320
487
  * needed) — we don't try to unpublish.
321
488
  */
322
- export function executePublish(plan: PublishPlan): void {
489
+ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPublishDeps()): void {
323
490
  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
- });
491
+ deps.log('→ delegating to `mise run publish`...');
492
+ deps.run('mise', ['run', 'publish'], plan.projectRoot);
329
493
  return;
330
494
  }
331
495
 
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`,
496
+ // 1. Bump package.json (and the lock) in place, keeping the original text for revert.
497
+ const originals = new Map<string, string>();
498
+ const rewrite = (file: string, next: (text: string) => string) => {
499
+ const text = fs.readFileSync(file, 'utf8');
500
+ originals.set(file, text);
501
+ fs.writeFileSync(file, next(text), 'utf8');
502
+ };
503
+ rewrite(plan.packageJsonPath, (text) => {
504
+ const pkg = JSON.parse(text);
505
+ pkg.version = plan.newVersion;
506
+ return JSON.stringify(pkg, null, 2) + '\n';
507
+ });
508
+ if (plan.lockPath) rewrite(plan.lockPath, (text) => bumpLockText(text, plan.newVersion));
509
+ const bumped = [...originals.keys()];
510
+ deps.log(
511
+ `✓ bumped ${bumped.map((f) => path.relative(plan.projectRoot, f)).join(' and ')}: ${plan.currentVersion} → ${plan.newVersion}`,
343
512
  );
344
513
 
345
514
  // 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);
515
+ deps.log('→ running `npm publish` (OTP prompt may appear)...');
348
516
  try {
349
- execFileSync('npm', ['publish'], { cwd: pkgDir, stdio: 'inherit' });
517
+ deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
350
518
  } 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
- );
519
+ for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
520
+ deps.log(`✗ npm publish failed (exit ${e?.status ?? '?'}); reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
355
521
  throw new Error('npm publish failed');
356
522
  }
357
- process.stderr.write(`✓ published ${plan.packageName}@${plan.newVersion}\n`);
523
+ deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
358
524
 
359
525
  // 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
- );
526
+ deps.run('git', ['add', '--', ...bumped], plan.projectRoot);
527
+ deps.run('git', ['commit', '-m', `release: v${plan.newVersion}`, '--', ...bumped], plan.projectRoot);
528
+ deps.run('git', ['tag', '-a', `v${plan.newVersion}`, '-m', `v${plan.newVersion}`], plan.projectRoot);
529
+ deps.log(`✓ committed + tagged v${plan.newVersion}`);
530
+
531
+ // 4. push
532
+ const push = plan.push;
533
+ if (push?.enabled && push.upstream) {
534
+ deps.log(`→ git push --follow-tags (${push.upstream})...`);
535
+ try {
536
+ deps.run('git', ['push', '--follow-tags'], plan.projectRoot);
537
+ } catch {
538
+ deps.log(`✗ git push failed — ${plan.packageName}@${plan.newVersion} is published and tagged here; push it: git push --follow-tags`);
539
+ throw new Error('git push failed');
540
+ }
541
+ deps.log(`✓ pushed to ${push.upstream} with tag v${plan.newVersion}`);
542
+ } else {
543
+ deps.log(
544
+ push?.enabled === false
545
+ ? '! not pushed (--no-push): git push --follow-tags'
546
+ : '! not pushed — the branch has no upstream: git push --follow-tags -u <remote> <branch>',
547
+ );
548
+ }
549
+
550
+ // 5. registry
551
+ waitForRegistry(plan.packageName, plan.newVersion, deps);
377
552
  }
378
553
 
379
554
  export function renderPublishPlan(plan: PublishPlan): string {
@@ -409,14 +584,28 @@ export function renderPublishPlan(plan: PublishPlan): string {
409
584
  lines.push(`| ${c.name} | ${c.ok ? 'OK' : 'FAIL'} | ${c.message} |`);
410
585
  }
411
586
  lines.push('');
412
- if (plan.preflightPassed) {
587
+ const fixable = !plan.preflightPassed && preflightFixable(plan);
588
+ if (plan.preflightPassed || fixable) {
589
+ const files = plan.lockPath ? 'package.json and package-lock.json' : 'package.json';
590
+ const push = plan.push;
413
591
  lines.push('## Pipeline (would execute on `vdx publish`)');
414
592
  lines.push('');
415
- lines.push(`1. Set \`version\` in package.json → \`${plan.newVersion}\``);
593
+ if (fixable) {
594
+ const fixes = plan.preflight.filter((c) => !c.ok && c.fix).map((c) => `\`${c.fix!.cmd} ${c.fix!.args.join(' ')}\``);
595
+ lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
596
+ }
597
+ lines.push(`1. Set \`version\` in ${files} → \`${plan.newVersion}\``);
416
598
  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}"\``);
599
+ lines.push(`3. \`git commit -m "release: v${plan.newVersion}"\` (${files})`);
418
600
  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\`)`);
601
+ lines.push(
602
+ !push?.enabled
603
+ ? '5. Not pushed (`--no-push`): `git push --follow-tags` is yours'
604
+ : push.upstream
605
+ ? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
606
+ : '5. Not pushed: the branch has no upstream',
607
+ );
608
+ lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 3 min)`);
420
609
  } else {
421
610
  lines.push('> Pre-flight failed. Use `--force` to bypass at your own risk.');
422
611
  }