@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 +2 -2
- package/package.json +1 -1
- package/src/index.ts +43 -15
- package/src/publish.ts +219 -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.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
|
|
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,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
|
|
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.
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
347
|
-
const pkgDir = path.dirname(plan.packageJsonPath);
|
|
493
|
+
deps.log('→ running `npm publish` (OTP prompt may appear)...');
|
|
348
494
|
try {
|
|
349
|
-
|
|
495
|
+
deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
|
|
350
496
|
} catch (e: any) {
|
|
351
|
-
fs.writeFileSync(
|
|
352
|
-
|
|
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
|
-
|
|
501
|
+
deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
|
|
358
502
|
|
|
359
503
|
// 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
}
|