@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 +2 -2
- package/package.json +1 -1
- package/src/ai.ts +5 -1
- package/src/index.ts +43 -15
- package/src/publish.ts +241 -52
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
347
|
-
const pkgDir = path.dirname(plan.packageJsonPath);
|
|
515
|
+
deps.log('→ running `npm publish` (OTP prompt may appear)...');
|
|
348
516
|
try {
|
|
349
|
-
|
|
517
|
+
deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
|
|
350
518
|
} catch (e: any) {
|
|
351
|
-
fs.writeFileSync(
|
|
352
|
-
|
|
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
|
-
|
|
523
|
+
deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
|
|
358
524
|
|
|
359
525
|
// 3. git add + commit + tag (at project root)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
}
|