@vodmal/vdx-cli 0.31.1 → 0.31.3

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
@@ -48,7 +48,7 @@ vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
48
48
  # Push the branch; one that requires checks on GitHub takes the commit once they pass
49
49
  vdx push [project-path] [--dry-run] # default: cwd
50
50
 
51
- # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project)
51
+ # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project; the GitHub token vdx asks with)
52
52
  vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
53
53
  vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
54
54
  vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.31.1",
3
+ "version": "0.31.3",
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
@@ -292,6 +292,20 @@ export function renderSessionName(template: string, vars: Record<string, string>
292
292
  return name.replace(/[.:]/g, '_');
293
293
  }
294
294
 
295
+ /**
296
+ * The session for an agent started next to running ones: `name`, `name-2`, `name-3`… — the
297
+ * first no agent of the project runs in (absent, or a shell left behind). Each agent has a
298
+ * session of its own: tmux keeps one current window per session, and an agent started as a
299
+ * window next to another moved every terminal on the session to it (vdx-ai DL #36).
300
+ */
301
+ export function freeSessionName(name: string, running: { pane: { session: string } }[]): string {
302
+ const busy = new Set(running.map((a) => a.pane.session));
303
+ for (let n = 1; ; n++) {
304
+ const candidate = n === 1 ? name : `${name}-${n}`;
305
+ if (!busy.has(candidate)) return candidate;
306
+ }
307
+ }
308
+
295
309
  export interface LaunchPlan {
296
310
  projectRoot: string;
297
311
  project: string;
@@ -748,7 +762,7 @@ export function sleepSync(ms: number): void {
748
762
  }
749
763
 
750
764
  /** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
751
- function askLine(prompt: string): string | null {
765
+ export function askLine(prompt: string): string | null {
752
766
  process.stderr.write(prompt);
753
767
  const buf = Buffer.alloc(256);
754
768
  let text = '';
@@ -804,7 +818,9 @@ function renderPlan(
804
818
  plan: LaunchPlan,
805
819
  multiplexer: Multiplexer,
806
820
  running: AgentPane[],
807
- focusedOf: (pid: number) => boolean = () => false,
821
+ focusedOf: (pid: number) => boolean,
822
+ /** The session a start would make: next to running agents, one of its own. */
823
+ session: string,
808
824
  ): string {
809
825
  const lines = [
810
826
  `vdx ai — ${plan.projectRoot}`,
@@ -819,7 +835,7 @@ function renderPlan(
819
835
  for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
820
836
  lines.push(
821
837
  multiplexer === 'tmux'
822
- ? ` session: tmux; a new session is named "${plan.sessionName}"`
838
+ ? ` session: tmux; a new session is named "${session}"`
823
839
  : ' session: this terminal (no tmux)',
824
840
  );
825
841
  if (multiplexer === 'tmux') {
@@ -1618,7 +1634,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1618
1634
  ? foldersWarning(denied, multiplexer === 'tmux', environment.session?.folders_fix, deps.home)
1619
1635
  : [];
1620
1636
  if (opts.dryRun) {
1621
- deps.out(renderPlan(plan, multiplexer, running, focusedOf));
1637
+ deps.out(
1638
+ renderPlan(plan, multiplexer, running, focusedOf, freeSessionName(plan.sessionName, alongside ? running : [])),
1639
+ );
1622
1640
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1623
1641
  for (const l of folders) deps.out(` ${l}\n`);
1624
1642
  ensureAuthor(projectRoot, poolDirs, deps, true);
@@ -1758,10 +1776,9 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1758
1776
  if (opts.restart) log(`note: no running ${plan.command} in this project — starting one`);
1759
1777
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
1760
1778
  const cmd = tmuxShellCommand(line, deps.shell);
1761
- const reuse = tmux.hasSession(plan.sessionName);
1762
- const paneId = reuse
1763
- ? tmux.newWindow(plan.sessionName, projectRoot, cmd)
1764
- : tmux.newSession(plan.sessionName, projectRoot, cmd);
1765
- log(`▶ ${plan.sessionName}${reuse ? ' (new window)' : ''}: ${line}`);
1766
- return finish(settleResumed(plan, paneId, deps), plan.sessionName, paneId, opts, deps);
1779
+ const session = freeSessionName(plan.sessionName, running);
1780
+ const reuse = tmux.hasSession(session);
1781
+ const paneId = reuse ? tmux.newWindow(session, projectRoot, cmd) : tmux.newSession(session, projectRoot, cmd);
1782
+ log(`▶ ${session}${reuse ? ' (new window)' : ''}: ${line}`);
1783
+ return finish(settleResumed(plan, paneId, deps), session, paneId, opts, deps);
1767
1784
  }
package/src/doctor.ts CHANGED
@@ -3,10 +3,11 @@ import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
6
+ import { effectiveAuthor, isGitRepo, rankAuthors, remoteParts, scanPool } from './author.ts';
7
7
  import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
8
8
  import { checkRepoHooks } from './clone-checks.ts';
9
- import { at, dict } from './narrow.ts';
9
+ import { githubToken, machineIo, type HostingIo } from './hosting.ts';
10
+ import { at, dict, errorMessage, str } from './narrow.ts';
10
11
  import {
11
12
  checkPersonalHooks,
12
13
  defaultPersonalHookDeps,
@@ -417,6 +418,61 @@ export function checkGitAuthor(ctx: DoctorCtx): CheckResult | null {
417
418
  };
418
419
  }
419
420
 
421
+ /**
422
+ * The token vdx asks GitHub with, for a repository whose origin is there — the audit
423
+ * (`branch-protection`) and `vdx push` ask. Without one GitHub answers 60 requests an
424
+ * hour; a token that no longer works is worse than none: every answer is a 401.
425
+ * `/rate_limit` tells both apart and costs nothing of the limit.
426
+ */
427
+ export function checkGithubToken(
428
+ ctx: DoctorCtx,
429
+ io: HostingIo | null = ctx.projectRoot ? machineIo(ctx.projectRoot) : null,
430
+ ghOnPath: boolean = findOnPath('gh') !== null,
431
+ platform: NodeJS.Platform = process.platform,
432
+ ): CheckResult | null {
433
+ if (ctx.projectRoot === null || io === null) return null;
434
+ const url = io.git(['remote', 'get-url', 'origin']);
435
+ if (!url || remoteParts(url).host !== 'github.com') return null;
436
+ const id = 'github-token';
437
+ const label = 'GitHub token';
438
+ if (io.env.VDX_OFFLINE) return { id, label, status: 'ok', message: 'VDX_OFFLINE is set — vdx does not ask GitHub' };
439
+ const t = githubToken(io);
440
+ let status: number;
441
+ let body: unknown;
442
+ try {
443
+ ({ status, body } = io.get('/rate_limit', t?.token ?? null));
444
+ } catch (e) {
445
+ return { id, label, status: 'warning', message: `GitHub did not answer: ${errorMessage(e)}` };
446
+ }
447
+ if (status === 401 && t) {
448
+ return {
449
+ id,
450
+ label,
451
+ status: 'warning',
452
+ message: `the token from ${t.source} does not work (GitHub: ${str(at(body, 'message')) ?? '401'}) — every answer is a 401`,
453
+ remedy: t.source === 'gh' ? 'gh auth login -h github.com' : `a working ${t.source}, or unset it`,
454
+ };
455
+ }
456
+ if (status !== 200) return { id, label, status: 'warning', message: `GitHub answered ${status} for /rate_limit` };
457
+ const limit = at(body, 'resources', 'core', 'limit');
458
+ const left = at(body, 'resources', 'core', 'remaining');
459
+ const hour = typeof limit === 'number' ? `${limit} requests an hour` : 'its limit';
460
+ const remaining = typeof left === 'number' ? `, ${left} left` : '';
461
+ if (t) return { id, label, status: 'ok', message: `from ${t.source}: ${hour}${remaining}` };
462
+ return {
463
+ id,
464
+ label,
465
+ status: 'warning',
466
+ message: `none — GitHub answers ${hour} without one${remaining}; an audit takes about 3, vdx push about 10`,
467
+ remedy: !ghOnPath
468
+ ? 'install gh (https://cli.github.com) and gh auth login, or set GH_TOKEN'
469
+ : io.env.SSH_CONNECTION && platform === 'darwin'
470
+ ? // m3, 2026-10-19: logged in, and `ssh m3 gh` saw no token, while tmux there did.
471
+ 'over ssh gh cannot read the macOS keychain where it keeps the token — in a local terminal or tmux it can; else gh auth login -h github.com'
472
+ : 'gh auth login -h github.com',
473
+ };
474
+ }
475
+
420
476
  const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
421
477
  checkVdxVersion,
422
478
  checkVdx,
@@ -430,6 +486,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
430
486
  (ctx) => checkRepoHooks(ctx.projectRoot),
431
487
  (ctx) => checkPersonalHooks(ctx.projectRoot),
432
488
  checkGitAuthor,
489
+ (ctx) => checkGithubToken(ctx),
433
490
  ];
434
491
 
435
492
  export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
package/src/hosting.ts CHANGED
@@ -170,7 +170,15 @@ export function githubRepo(io: HostingIo, remote: string): { repo: string; token
170
170
  // The host only: a URL can carry a token.
171
171
  if (host !== 'github.com')
172
172
  return { unknown: `${remote} is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
173
- return { repo, token: io.env.GH_TOKEN || io.env.GITHUB_TOKEN || io.ghToken() };
173
+ return { repo, token: githubToken(io)?.token ?? null };
174
+ }
175
+
176
+ /** The token vdx asks GitHub with and where it comes from — `GH_TOKEN`, `GITHUB_TOKEN`, then gh, as gh itself picks. */
177
+ export function githubToken(io: HostingIo): { token: string; source: 'GH_TOKEN' | 'GITHUB_TOKEN' | 'gh' } | null {
178
+ if (io.env.GH_TOKEN) return { token: io.env.GH_TOKEN, source: 'GH_TOKEN' };
179
+ if (io.env.GITHUB_TOKEN) return { token: io.env.GITHUB_TOKEN, source: 'GITHUB_TOKEN' };
180
+ const gh = io.ghToken();
181
+ return gh ? { token: gh, source: 'gh' } : null;
174
182
  }
175
183
 
176
184
  function isUnknownRepo(v: { repo: string } | Unknown): v is Unknown {
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  } from './publish.ts';
36
36
  import { resolveDefaultRubric } from './defaults.ts';
37
37
  import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate } from './gate.ts';
38
- import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
38
+ import { askLine, runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
39
39
  import { isConversationId } from './conversations.ts';
40
40
 
41
41
  const DEFAULT_RUBRIC = resolveDefaultRubric();
@@ -83,7 +83,7 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
83
83
  @host the same command on <host> over ssh: its vdx, profile and tmux
84
84
  --new a new conversation instead of continuing the last one; an agent
85
85
  already running here keeps running, the new one starts next to
86
- it (a new tmux window)
86
+ it, in a tmux session of its own (<name>-2, -3, …)
87
87
  --focused a new conversation, as --new, in a session nothing wakes: the
88
88
  agent, its hooks and MCP servers get VDX_FOCUSED=1 — for the
89
89
  plugins that wake sessions to leave it alone — and the profile's
@@ -156,7 +156,10 @@ mise.toml is overwritten only with --force.
156
156
  git commit and tag, then \`git push --follow-tags\` to the branch's upstream —
157
157
  through its checks when it requires some, as \`vdx push\` does: the pre-flight
158
158
  then refuses local commits not pushed yet, npm would get them before CI;
159
- a wait until the registry shows the new version (up to 3 min).
159
+ a wait until the registry shows the new version (up to 10 min from npm publish;
160
+ Ctrl-C there stops only the wait).
161
+ Nothing in the package's directory changed since the tag of its version: the
162
+ same package under a new number — at a terminal a question, Enter releases nothing.
160
163
  --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
161
164
  pre-flight; --no-push leaves the commit and the tag local.
162
165
  `,
@@ -193,7 +196,9 @@ or GitHub stopped answering; a plain git push exits with git's own code.
193
196
  help: `Checks this machine for what vdx needs (Node, git, mise, npm auth, docker, the
194
197
  Claude Code plugin) and, inside a project (default: cwd), its commit author and
195
198
  its git hooks: are the hooks the repository declares on in this clone, and do
196
- the personal hooks of your profile (git.hooks) run on this machine. Reads only.
199
+ the personal hooks of your profile (git.hooks) run on this machine; with origin on
200
+ GitHub, the token vdx asks GitHub with ($GH_TOKEN, $GITHUB_TOKEN or gh) and
201
+ whether it works — without one GitHub answers 60 requests an hour. Reads only.
197
202
  Exit 2 when something is missing.
198
203
 
199
204
  --check for the agent's own session (the vdx plugin's SessionStart hook): one
@@ -438,7 +443,8 @@ function cmdPublish(opts: ParsedArgs): void {
438
443
  const deps = defaultPublishDeps();
439
444
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
440
445
  if (!plan.preflightPassed && !planOpts.force && interactive && preflightFixable(plan)) {
441
- for (const c of plan.preflight.filter((c) => !c.ok && c.fix)) {
446
+ const fixes = plan.preflight.filter((c) => !c.ok && c.fix);
447
+ for (const c of fixes) {
442
448
  process.stderr.write(`\n→ ${c.name}: ${c.fix!.cmd} ${c.fix!.args.join(' ')}\n`);
443
449
  try {
444
450
  deps.run(c.fix!.cmd, c.fix!.args, projectRoot);
@@ -447,11 +453,27 @@ function cmdPublish(opts: ParsedArgs): void {
447
453
  process.exit(2);
448
454
  }
449
455
  }
450
- plan = replan();
451
- process.stdout.write('\n' + renderPublishPlan(plan));
456
+ if (fixes.length > 0) {
457
+ plan = replan();
458
+ process.stdout.write('\n' + renderPublishPlan(plan));
459
+ }
460
+ }
461
+
462
+ // A failure the person may waive (nothing changed since the release): a question, Enter no.
463
+ let waived = false;
464
+ const left = plan.preflight.filter((c) => !c.ok);
465
+ if (!planOpts.force && interactive && left.length > 0 && left.every((c) => c.waive)) {
466
+ for (const c of left) {
467
+ process.stderr.write(`\n${c.name}: ${c.message}\n`);
468
+ if (!/^y(es)?$/i.test(askLine(c.waive!) ?? '')) {
469
+ process.stderr.write('nothing released\n');
470
+ process.exit(2);
471
+ }
472
+ }
473
+ waived = true;
452
474
  }
453
475
 
454
- if (!plan.preflightPassed && !planOpts.force) {
476
+ if (!plan.preflightPassed && !planOpts.force && !waived) {
455
477
  process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
456
478
  process.exit(2);
457
479
  }
package/src/publish.ts CHANGED
@@ -24,6 +24,8 @@ export interface PreflightCheck {
24
24
  message: string;
25
25
  /** A failed check a command at the terminal can fix — `npm login` for an expired session. */
26
26
  fix?: { cmd: string; args: string[] };
27
+ /** A failed check the person at the terminal may waive: the question, Enter answering no. */
28
+ waive?: string;
27
29
  }
28
30
 
29
31
  /** Where the release goes after the tag. */
@@ -311,6 +313,9 @@ export function planPublish(
311
313
  });
312
314
 
313
315
  preflight.push(checkNpmAuth(publishRegistry(pkg, npm), npm));
316
+ preflight.push(
317
+ checkChangesSinceRelease(opts.projectRoot, path.dirname(pkgJsonPath), currentVersion, newVersion, git),
318
+ );
314
319
 
315
320
  let published: string | null = null;
316
321
  let registryError: string | null = null;
@@ -397,10 +402,50 @@ export function planPublish(
397
402
  };
398
403
  }
399
404
 
400
- /** Every failed check has a command that fixes it — run at a terminal, then plan again. */
405
+ /**
406
+ * Anything to release? 0.30.0 went out on 2026-10-18 with nothing since v0.29.0 but
407
+ * its release commit (hosting-extension Sidetrack #6). Nothing in the package's
408
+ * directory since the tag of the current version — the same package under a new
409
+ * number; a terminal asks, Enter answering no.
410
+ */
411
+ export function checkChangesSinceRelease(
412
+ projectRoot: string,
413
+ packageDir: string,
414
+ current: string,
415
+ next: string,
416
+ git: GitRunner,
417
+ ): PreflightCheck {
418
+ const name = 'changes-since-release';
419
+ const tag = `v${current}`;
420
+ try {
421
+ git(['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], projectRoot);
422
+ } catch {
423
+ return { name, ok: true, message: `OK (no tag ${tag} to compare with)` };
424
+ }
425
+ const where = path.relative(projectRoot, packageDir) || '.';
426
+ const shown = where === '.' ? `${path.basename(packageDir)}/` : `${where}/`;
427
+ let count: number;
428
+ try {
429
+ count = parseInt(git(['rev-list', '--count', `${tag}..HEAD`, '--', where], projectRoot).trim(), 10) || 0;
430
+ } catch {
431
+ return { name, ok: true, message: `OK (git could not compare with ${tag})` };
432
+ }
433
+ if (count > 0) return { name, ok: true, message: `OK (${count} commit(s) in ${shown} since ${tag})` };
434
+ return {
435
+ name,
436
+ ok: false,
437
+ message: `nothing in ${shown} has changed since ${tag}: ${next} would be the package ${current} is`,
438
+ waive: `Release ${next} anyway, the same package under a new number? [y/N] `,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Every failed check is resolved at a terminal: a command that fixes it (run, then
444
+ * plan again) or a question the person may answer yes to.
445
+ */
401
446
  export function preflightFixable(plan: PublishPlan): boolean {
402
447
  const failed = plan.preflight.filter((c) => !c.ok);
403
- return failed.length > 0 && failed.every((c) => c.fix);
448
+ return failed.length > 0 && failed.every((c) => c.fix || c.waive);
404
449
  }
405
450
 
406
451
  /** Runs a command on this terminal (stdio inherited); throws on a non-zero exit. */
@@ -431,7 +476,7 @@ export function defaultPublishDeps(): PublishDeps {
431
476
  npm: runNpm,
432
477
  log: (line) => process.stderr.write(line + '\n'),
433
478
  sleep: sleepSync,
434
- registryWaitMs: 180_000,
479
+ registryWaitMs: 10 * 60_000,
435
480
  progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
436
481
  gatePush: (target, projectRoot) => pushThroughGate(target, defaultGateDeps(projectRoot)),
437
482
  };
@@ -447,19 +492,22 @@ export function bumpLockText(text: string, version: string): string {
447
492
  }
448
493
 
449
494
  /**
450
- * npm answers `202 Accepted` and the version shows up a minute or two later;
451
- * until then `npm view` and `npx` still see the previous one. Waiting here
452
- * turns "is it out?" into an answer. A version that does not show up in time
453
- * is not a failure: npm took it.
495
+ * npm answers `202 Accepted` and the version shows up minutes later — 0.30.0
496
+ * about 6 min after publish, 0.31.2 past 5 (hosting-extension Sidetrack #9); until
497
+ * then `npm view` and `npx` still see the previous one. Waiting here turns "is it
498
+ * out?" into an answer. The time counts from `npm publish` (`since`): the push
499
+ * through CI before the wait is part of it. A version that does not show up in
500
+ * time is not a failure: npm took it.
454
501
  */
455
- export function waitForRegistry(name: string, version: string, deps: PublishDeps): boolean {
456
- const started = Date.now();
502
+ export function waitForRegistry(name: string, version: string, deps: PublishDeps, since = Date.now()): boolean {
503
+ const started = since;
457
504
  const deadline = started + deps.registryWaitMs;
458
505
  const limit = Math.round(deps.registryWaitMs / 1000);
459
506
  const seconds = () => Math.round((Date.now() - started) / 1000);
460
507
  deps.log(
461
508
  `→ waiting for the registry to show ${name}@${version} — npm has taken it; ` +
462
- `the registry usually shows it within ~2 min (giving it ${limit} s)`,
509
+ `the registry has taken up to 6 min to show a version (giving it ${limit} s from publish; ` +
510
+ `Ctrl-C stops only this wait)`,
463
511
  );
464
512
  const shown = () => {
465
513
  try {
@@ -556,6 +604,7 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
556
604
  } finally {
557
605
  for (const s of signals) process.off(s, holdOut);
558
606
  }
607
+ const publishedAt = Date.now();
559
608
  deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
560
609
 
561
610
  // 3. git add + commit + tag (at project root)
@@ -597,7 +646,7 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
597
646
  }
598
647
 
599
648
  // 5. registry
600
- waitForRegistry(plan.packageName, plan.newVersion, deps);
649
+ waitForRegistry(plan.packageName, plan.newVersion, deps, publishedAt);
601
650
  }
602
651
 
603
652
  export function renderPublishPlan(plan: PublishPlan): string {
@@ -643,7 +692,10 @@ export function renderPublishPlan(plan: PublishPlan): string {
643
692
  const fixes = plan.preflight
644
693
  .filter((c) => !c.ok && c.fix)
645
694
  .map((c) => `\`${c.fix!.cmd} ${c.fix!.args.join(' ')}\``);
646
- lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
695
+ if (fixes.length > 0) lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
696
+ for (const c of plan.preflight.filter((c) => !c.ok && c.waive)) {
697
+ lines.push(`0. At a terminal: ${c.waive!.trim()} — Enter releases nothing`);
698
+ }
647
699
  }
648
700
  lines.push(`1. Set \`version\` in ${files} → \`${plan.newVersion}\``);
649
701
  lines.push(`2. \`npm publish\` (cwd = directory containing package.json)`);
@@ -658,7 +710,7 @@ export function renderPublishPlan(plan: PublishPlan): string {
658
710
  ? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
659
711
  : '5. Not pushed: the branch has no upstream',
660
712
  );
661
- lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 3 min)`);
713
+ lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 10 min from publish)`);
662
714
  } else {
663
715
  lines.push('> Pre-flight failed. Use `--force` to bypass at your own risk.');
664
716
  }