@vodmal/vdx-cli 0.18.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 +10 -4
- package/package.json +1 -1
- package/src/ai.ts +230 -10
- package/src/conversations.ts +299 -0
- package/src/index.ts +70 -19
- package/src/publish.ts +219 -52
package/README.md
CHANGED
|
@@ -42,13 +42,13 @@ 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]
|
|
49
49
|
|
|
50
50
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
51
|
-
vdx ai[@host] [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
51
|
+
vdx ai[@host] [project-path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
52
52
|
vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
|
|
53
53
|
|
|
54
54
|
# What a command does and takes; nothing runs (ai: also where launch flags live)
|
|
@@ -72,9 +72,10 @@ 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
|
-
# 4. Start the agent here, continuing its last conversation (--new: a new one
|
|
77
|
+
# 4. Start the agent here, continuing its last conversation (--new: a new one;
|
|
78
|
+
# another machine's conversation is continued on that machine);
|
|
78
79
|
# re-run to attach, --restart to apply a changed profile
|
|
79
80
|
vdx ai
|
|
80
81
|
|
|
@@ -91,6 +92,11 @@ name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machin
|
|
|
91
92
|
profile and tmux, the path taken under its home directory; `--detach` only
|
|
92
93
|
starts it. The machine's own label (`$VDX_HOST`) runs here. Where git knows no
|
|
93
94
|
commit author, `vdx ai` proposes one (Enter writes it into `.git/config`).
|
|
95
|
+
For Claude Code `vdx ai` picks the conversation: each one is marked with the
|
|
96
|
+
machine it was last started on (`vdx ai --check`, run by the plugin's hook, writes
|
|
97
|
+
the label in), the running ones are asked of the other machines over ssh, and
|
|
98
|
+
another machine's conversation is continued there. When the choice is not plain
|
|
99
|
+
it lists them — Enter takes the newest; without a terminal, this machine's.
|
|
94
100
|
|
|
95
101
|
Every command takes `--help` (`-h`) and runs nothing with it. An unknown option,
|
|
96
102
|
a stray argument or a value flag without its value is refused with exit 2
|
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/ai.ts
CHANGED
|
@@ -8,6 +8,20 @@ import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './
|
|
|
8
8
|
import { loadManifest } from './manifest.ts';
|
|
9
9
|
import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
|
|
10
10
|
import type { Predicate } from './rubric.ts';
|
|
11
|
+
import {
|
|
12
|
+
type Conversation,
|
|
13
|
+
describeConversation,
|
|
14
|
+
describeLive,
|
|
15
|
+
LIVE_SESSIONS_SCRIPT,
|
|
16
|
+
type LiveSession,
|
|
17
|
+
latestPerMachine,
|
|
18
|
+
listConversations,
|
|
19
|
+
liveConversationId,
|
|
20
|
+
liveSessionsHere,
|
|
21
|
+
machineLine,
|
|
22
|
+
machineOf,
|
|
23
|
+
parseLiveSessions,
|
|
24
|
+
} from './conversations.ts';
|
|
11
25
|
|
|
12
26
|
/**
|
|
13
27
|
* `vdx ai` — start the person's agent in a project, with the flags their
|
|
@@ -17,7 +31,9 @@ import type { Predicate } from './rubric.ts';
|
|
|
17
31
|
*
|
|
18
32
|
* A start continues the project's last conversation (the profile's
|
|
19
33
|
* `resume_args`) — after a reboot `vdx ai` brings the agent back where it was;
|
|
20
|
-
* `--new` starts a new conversation.
|
|
34
|
+
* `--new` starts a new conversation. For Claude Code vdx picks the
|
|
35
|
+
* conversation itself, and continues another machine's on that machine
|
|
36
|
+
* (see chooseConversation).
|
|
21
37
|
*
|
|
22
38
|
* The profile is personal (which agent, which permissions), so it is never
|
|
23
39
|
* bundled into the CLI: it is read from `$VDX_ENVIRONMENT` or
|
|
@@ -581,6 +597,8 @@ export interface AiOptions {
|
|
|
581
597
|
restart: boolean;
|
|
582
598
|
/** `--new`: start a new conversation — leave out the profile's resume args. */
|
|
583
599
|
fresh: boolean;
|
|
600
|
+
/** `--conversation <id>`: continue this Claude Code conversation. */
|
|
601
|
+
conversation?: string;
|
|
584
602
|
detach: boolean;
|
|
585
603
|
dryRun: boolean;
|
|
586
604
|
}
|
|
@@ -600,6 +618,18 @@ export interface AiDeps {
|
|
|
600
618
|
confirmTimeoutMs: number;
|
|
601
619
|
/** One line from the person at the terminal, or null when there is none. */
|
|
602
620
|
ask: (prompt: string) => string | null;
|
|
621
|
+
/** This vdx's version, named to the vdx on another machine. */
|
|
622
|
+
version?: string;
|
|
623
|
+
/** Run ssh — capturing its output, or on this terminal; null when ssh is not on PATH. */
|
|
624
|
+
ssh?: (args: string[], capture: boolean) => { status: number; stdout: string } | null;
|
|
625
|
+
/** Claude Code processes alive on this machine; read from its session files when not given. */
|
|
626
|
+
liveHere?: () => LiveSession[];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
630
|
+
const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
|
|
631
|
+
if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
|
|
632
|
+
return { status: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
603
633
|
}
|
|
604
634
|
|
|
605
635
|
export function sleepSync(ms: number): void {
|
|
@@ -643,6 +673,7 @@ export function defaultDeps(): AiDeps {
|
|
|
643
673
|
sleep: sleepSync,
|
|
644
674
|
confirmTimeoutMs: 30_000,
|
|
645
675
|
ask: askLine,
|
|
676
|
+
ssh: runSsh,
|
|
646
677
|
};
|
|
647
678
|
}
|
|
648
679
|
|
|
@@ -826,6 +857,7 @@ export function remoteAiArgs(input: {
|
|
|
826
857
|
opts.restart && '--restart',
|
|
827
858
|
opts.detach && '--detach',
|
|
828
859
|
opts.dryRun && '--dry-run',
|
|
860
|
+
opts.conversation && `--conversation ${shellQuote(opts.conversation)}`,
|
|
829
861
|
].filter((f): f is string => Boolean(f));
|
|
830
862
|
const v = shellQuote(version);
|
|
831
863
|
const script =
|
|
@@ -890,20 +922,159 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
|
|
|
890
922
|
}
|
|
891
923
|
}
|
|
892
924
|
|
|
893
|
-
export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
|
|
925
|
+
export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string = deps.version ?? ''): number {
|
|
894
926
|
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
895
927
|
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
896
928
|
const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
|
|
897
|
-
deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}`);
|
|
898
|
-
const res =
|
|
899
|
-
if (
|
|
929
|
+
deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
|
|
930
|
+
const res = (deps.ssh ?? runSsh)(args, false);
|
|
931
|
+
if (res === null) {
|
|
900
932
|
deps.log('vdx ai: `ssh` not found on PATH');
|
|
901
933
|
return 127;
|
|
902
934
|
}
|
|
903
935
|
if (opts.detach && res.status === EXIT_OK) {
|
|
904
936
|
deps.log(`attach from here: vdx ai@${host} ${shellQuote(projectPath)}`);
|
|
905
937
|
}
|
|
906
|
-
return res.status
|
|
938
|
+
return res.status;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* The Claude Code sessions alive on `host` right now; null when it does not
|
|
943
|
+
* answer over ssh (BatchMode: a password prompt counts as no answer).
|
|
944
|
+
*/
|
|
945
|
+
export function askMachine(host: string, deps: AiDeps): LiveSession[] | null {
|
|
946
|
+
const res = (deps.ssh ?? runSsh)(
|
|
947
|
+
['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, `sh -c ${shellQuote(LIVE_SESSIONS_SCRIPT)}`],
|
|
948
|
+
true,
|
|
949
|
+
);
|
|
950
|
+
return res && res.status === 0 ? parseLiveSessions(res.stdout) : null;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** What a start continues: resume args here, or a conversation on another machine. */
|
|
954
|
+
export interface ConversationChoice {
|
|
955
|
+
args: string[];
|
|
956
|
+
/** Continue it on that machine (`vdx ai@<host>`); `reachable` — it answered over ssh just now. */
|
|
957
|
+
remote?: { host: string; id: string; reachable: boolean };
|
|
958
|
+
/** Continue it here only after a yes: another machine's that cannot be asked, or one running here already. */
|
|
959
|
+
askHere?: { id: string; why: string };
|
|
960
|
+
/** For the person: what was found and what is taken. */
|
|
961
|
+
lines: string[];
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export function isClaude(command: string): boolean {
|
|
965
|
+
return path.basename(command) === 'claude';
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const resumeId = (id: string) => ['--resume', id];
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Which conversation a start continues. Other agents keep the profile's
|
|
972
|
+
* resume_args. For Claude Code vdx reads the project's conversations and the
|
|
973
|
+
* machine each belongs to (conversations.ts): `--continue` would take the
|
|
974
|
+
* newest one in the folder, and with the folder synced between machines that
|
|
975
|
+
* can be a conversation another machine is still writing to.
|
|
976
|
+
*
|
|
977
|
+
* Every other machine this project's conversations name is asked over ssh
|
|
978
|
+
* which of them run there right now; this machine's running ones come from
|
|
979
|
+
* its own session files. A running conversation is listed even when it is not
|
|
980
|
+
* its machine's newest.
|
|
981
|
+
*
|
|
982
|
+
* Another machine's conversation is continued on that machine, so two
|
|
983
|
+
* machines never write to one conversation; one whose machine is unknown, or
|
|
984
|
+
* cannot be reached, or that already runs here, is continued here only after a
|
|
985
|
+
* yes. When the choice is not plain, a person at a terminal picks — Enter
|
|
986
|
+
* takes the newest overall; without a terminal it is this machine's newest
|
|
987
|
+
* idle one, never another's.
|
|
988
|
+
*/
|
|
989
|
+
export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
|
|
990
|
+
if (plan.resumeArgs.length === 0 || !isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
991
|
+
const host = plan.host;
|
|
992
|
+
const same = (a: string | null, b: string | null) => (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
|
993
|
+
const all = listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
|
|
994
|
+
if (all.length === 0) return { args: [], lines: ['no conversation of this project yet — starting a new one'] };
|
|
995
|
+
|
|
996
|
+
// Who runs what right now: this machine from its session files, the others over ssh.
|
|
997
|
+
const reach = new Map<string, LiveSession[] | null>();
|
|
998
|
+
for (const c of all) {
|
|
999
|
+
if (c.machine && !same(c.machine, host) && ![...reach.keys()].some((m) => same(m, c.machine))) {
|
|
1000
|
+
reach.set(c.machine, askMachine(c.machine, deps));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const live = new Map<string, { machine: string; session: LiveSession; here: boolean }[]>();
|
|
1004
|
+
const addLive = (machine: string, session: LiveSession, here: boolean) =>
|
|
1005
|
+
live.set(session.conversation, [...(live.get(session.conversation) ?? []), { machine, session, here }]);
|
|
1006
|
+
for (const l of (deps.liveHere ?? (() => liveSessionsHere(deps.env, deps.home)))()) addLive(host, l, true);
|
|
1007
|
+
for (const [m, sessions] of reach) for (const l of sessions ?? []) addLive(m, l, false);
|
|
1008
|
+
|
|
1009
|
+
const machine = (c: Conversation) => live.get(c.id)?.find((l) => !l.here)?.machine ?? machineOf(c, host);
|
|
1010
|
+
const runsHere = (c: Conversation) => live.get(c.id)?.find((l) => l.here);
|
|
1011
|
+
const isOurs = (c: Conversation) => same(machine(c), host);
|
|
1012
|
+
const idleHere = (c: Conversation) => isOurs(c) && !runsHere(c);
|
|
1013
|
+
const found = [...new Set([...latestPerMachine(all, machine), ...all.filter((c) => live.has(c.id))])].sort(
|
|
1014
|
+
(a, b) => b.updated.getTime() - a.updated.getTime(),
|
|
1015
|
+
);
|
|
1016
|
+
const show = (c: Conversation) => describeConversation(c, machine(c));
|
|
1017
|
+
const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(l.machine, l.session, l.here));
|
|
1018
|
+
|
|
1019
|
+
const take = (c: Conversation): ConversationChoice => {
|
|
1020
|
+
const m = machine(c);
|
|
1021
|
+
const here = runsHere(c);
|
|
1022
|
+
if (here) {
|
|
1023
|
+
return {
|
|
1024
|
+
args: [],
|
|
1025
|
+
askHere: { id: c.id, why: `It runs here right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
|
|
1026
|
+
lines: [`chosen: ${show(c)}`],
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
if (isOurs(c)) return { args: resumeId(c.id), lines: [`continuing ${show(c)}`] };
|
|
1030
|
+
if (m) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
|
|
1031
|
+
return {
|
|
1032
|
+
args: [],
|
|
1033
|
+
askHere: { id: c.id, why: 'Its machine is unknown (never typed in here); if it runs there, both machines will write to it' },
|
|
1034
|
+
lines: [`chosen: ${show(c)}`],
|
|
1035
|
+
};
|
|
1036
|
+
};
|
|
1037
|
+
if (found.every(idleHere)) return take(found[0]!);
|
|
1038
|
+
|
|
1039
|
+
const firstIdle = found.find(idleHere);
|
|
1040
|
+
if (canAsk && deps.interactive) {
|
|
1041
|
+
deps.log('conversations of this project:');
|
|
1042
|
+
found.forEach((c, i) => {
|
|
1043
|
+
const marks = [i === 0 && 'newest', c === firstIdle && 'this machine', !machine(c) && 'machine unknown', ...running(c)];
|
|
1044
|
+
const m = marks.filter(Boolean);
|
|
1045
|
+
deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
|
|
1046
|
+
});
|
|
1047
|
+
deps.log(' n) a new conversation');
|
|
1048
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
1049
|
+
const answer = deps.ask(`Which one to continue? [1]: `);
|
|
1050
|
+
if (answer === null) break;
|
|
1051
|
+
const a = answer.trim();
|
|
1052
|
+
if (/^n(ew)?$/i.test(a)) return { args: [], lines: ['a new conversation'] };
|
|
1053
|
+
const c = a === '' ? found[0] : /^\d+$/.test(a) ? found[Number(a) - 1] : undefined;
|
|
1054
|
+
if (c) return take(c);
|
|
1055
|
+
deps.log(` 1–${found.length}, or n`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const root = shellQuote(plan.projectRoot);
|
|
1059
|
+
const notes = found
|
|
1060
|
+
.filter((c) => !idleHere(c))
|
|
1061
|
+
.map((c) => {
|
|
1062
|
+
const m = machine(c);
|
|
1063
|
+
const now = running(c);
|
|
1064
|
+
const which =
|
|
1065
|
+
`${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
|
|
1066
|
+
(now.length ? `, ${now.join(', ')}` : '');
|
|
1067
|
+
if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
|
|
1068
|
+
return m
|
|
1069
|
+
? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
|
|
1070
|
+
: `note: ${which} is of an unknown machine (never typed in here) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
|
|
1071
|
+
});
|
|
1072
|
+
for (const [m, sessions] of reach) if (sessions === null) notes.push(`note: ${m} does not answer over ssh — what runs there is not known`);
|
|
1073
|
+
if (firstIdle) {
|
|
1074
|
+
const chosen = take(firstIdle);
|
|
1075
|
+
return { ...chosen, lines: [...chosen.lines, ...notes] };
|
|
1076
|
+
}
|
|
1077
|
+
return { args: [], lines: ['no idle conversation of this machine — starting a new one', ...notes] };
|
|
907
1078
|
}
|
|
908
1079
|
|
|
909
1080
|
interface Prepared {
|
|
@@ -994,10 +1165,11 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
|
|
|
994
1165
|
const { projectRoot, profilePath, plan } = prepared;
|
|
995
1166
|
|
|
996
1167
|
const lines = [
|
|
997
|
-
|
|
1168
|
+
`${machineLine(plan.host)}; the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
|
|
998
1169
|
`${profilePath} — agent.args for every project, agent.when[] for a class of projects. ` +
|
|
999
1170
|
`Hand the user \`vdx ai\`, not \`${plan.command} --<flag>\`; a flag a class of projects needs is a ` +
|
|
1000
|
-
`condition in the profile, not an instruction to type.
|
|
1171
|
+
`condition in the profile, not an instruction to type. A project's agent on another machine: ` +
|
|
1172
|
+
`\`vdx ai@<machine>\`. More: \`vdx ai --help\`.`,
|
|
1001
1173
|
];
|
|
1002
1174
|
const own = findOwnAgent(deps.processes(), selfPid, plan.command);
|
|
1003
1175
|
if (!own) {
|
|
@@ -1032,6 +1204,10 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1032
1204
|
if (typeof prepared === 'number') return prepared;
|
|
1033
1205
|
const { projectRoot, environment, profileDir } = prepared;
|
|
1034
1206
|
let { plan } = prepared;
|
|
1207
|
+
if (opts.conversation && !isClaude(plan.command)) {
|
|
1208
|
+
log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
|
|
1209
|
+
return EXIT_USAGE;
|
|
1210
|
+
}
|
|
1035
1211
|
// A start continues the last conversation unless asked for a new one.
|
|
1036
1212
|
if (opts.fresh) plan = { ...plan, resumeArgs: [] };
|
|
1037
1213
|
|
|
@@ -1045,13 +1221,49 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1045
1221
|
multiplexer === 'tmux'
|
|
1046
1222
|
? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
|
|
1047
1223
|
: [];
|
|
1224
|
+
const current = running[0];
|
|
1225
|
+
|
|
1226
|
+
// What a start continues. A restart keeps the conversation the agent is in.
|
|
1227
|
+
let choice: ConversationChoice | null = null;
|
|
1228
|
+
if (!current || opts.restart) {
|
|
1229
|
+
if (opts.conversation) choice = { args: resumeId(opts.conversation), lines: [] };
|
|
1230
|
+
else if (current && plan.resumeArgs.length > 0 && isClaude(plan.command)) {
|
|
1231
|
+
const live = liveConversationId(deps.env, deps.home, current.proc.pid);
|
|
1232
|
+
choice = live
|
|
1233
|
+
? { args: resumeId(live), lines: [`the agent is in conversation ${live.slice(0, 8)} — resuming it`] }
|
|
1234
|
+
: chooseConversation(plan, deps, false);
|
|
1235
|
+
} else choice = chooseConversation(plan, deps, !opts.dryRun);
|
|
1236
|
+
plan = { ...plan, resumeArgs: choice.args };
|
|
1237
|
+
}
|
|
1048
1238
|
|
|
1049
1239
|
const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
|
|
1050
1240
|
if (opts.dryRun) {
|
|
1051
1241
|
deps.out(renderPlan(plan, multiplexer, running));
|
|
1242
|
+
for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
|
|
1052
1243
|
ensureAuthor(projectRoot, poolDirs, deps, true);
|
|
1053
1244
|
return EXIT_OK;
|
|
1054
1245
|
}
|
|
1246
|
+
for (const l of choice?.lines ?? []) log(l);
|
|
1247
|
+
|
|
1248
|
+
let askHere = choice?.askHere;
|
|
1249
|
+
if (choice?.remote) {
|
|
1250
|
+
const { host, id, reachable } = choice.remote;
|
|
1251
|
+
if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps);
|
|
1252
|
+
log(`✗ ${host} does not answer over ssh — its conversation ${id.slice(0, 8)} cannot be continued there`);
|
|
1253
|
+
askHere = { id, why: `If it still runs on ${host}, both machines will write to it` };
|
|
1254
|
+
}
|
|
1255
|
+
if (askHere) {
|
|
1256
|
+
const { id, why } = askHere;
|
|
1257
|
+
const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} here, on ${plan.host}? ${why}. [y/N]: `) : null;
|
|
1258
|
+
if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
|
|
1259
|
+
log(
|
|
1260
|
+
`nothing started. Here: vdx ai ${shellQuote(projectRoot)} --conversation ${id}` +
|
|
1261
|
+
` · a new conversation: vdx ai --new ${shellQuote(projectRoot)}`,
|
|
1262
|
+
);
|
|
1263
|
+
return EXIT_LAUNCH_FAILED;
|
|
1264
|
+
}
|
|
1265
|
+
plan = { ...plan, resumeArgs: resumeId(id) };
|
|
1266
|
+
}
|
|
1055
1267
|
ensureAuthor(projectRoot, poolDirs, deps, false);
|
|
1056
1268
|
|
|
1057
1269
|
if (multiplexer === 'none') {
|
|
@@ -1078,7 +1290,6 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1078
1290
|
return res.status ?? 1;
|
|
1079
1291
|
}
|
|
1080
1292
|
|
|
1081
|
-
const current = running[0];
|
|
1082
1293
|
if (running.length > 1) {
|
|
1083
1294
|
log(
|
|
1084
1295
|
`note: ${running.length} panes run ${plan.command} in this project: ` +
|
|
@@ -1088,13 +1299,22 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1088
1299
|
|
|
1089
1300
|
if (current && !opts.restart) {
|
|
1090
1301
|
if (opts.fresh) log(`note: ${current.pane.session} already runs ${plan.command} — --new replaces it only with --restart`);
|
|
1302
|
+
if (opts.conversation) {
|
|
1303
|
+
const live = liveConversationId(deps.env, deps.home, current.proc.pid);
|
|
1304
|
+
if (live !== opts.conversation) {
|
|
1305
|
+
log(
|
|
1306
|
+
`note: ${current.pane.session} is in ${live ? `conversation ${live.slice(0, 8)}` : 'another conversation'} — ` +
|
|
1307
|
+
`to switch it: vdx ai --restart ${shellQuote(projectRoot)} --conversation ${opts.conversation}`,
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1091
1311
|
const missing = missingArgs(current.proc.args, plan.args);
|
|
1092
1312
|
if (missing.length > 0) {
|
|
1093
1313
|
log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
|
|
1094
1314
|
log(
|
|
1095
1315
|
` to apply the profile: vdx ai --restart${opts.fresh ? ' --new' : ''} ${shellQuote(projectRoot)}` +
|
|
1096
1316
|
(plan.resumeArgs.length
|
|
1097
|
-
? ` (stops the running agent, then resumes
|
|
1317
|
+
? ` (stops the running agent, then resumes its conversation)`
|
|
1098
1318
|
: opts.fresh
|
|
1099
1319
|
? ' (stops the running agent and starts a new conversation)'
|
|
1100
1320
|
: ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claude Code conversations of a project, and the machine each one was last
|
|
6
|
+
* started on.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code keeps a conversation as `<config>/projects/<dir>/<id>.jsonl`,
|
|
9
|
+
* where `<dir>` is the working directory with every character other than a
|
|
10
|
+
* letter or a digit turned into `-`. That folder may be synced between
|
|
11
|
+
* machines (it is on the owner's: Syncthing), so the newest conversation of a
|
|
12
|
+
* project can be another machine's — and `claude --continue` would take it
|
|
13
|
+
* there, while that machine still writes to it.
|
|
14
|
+
*
|
|
15
|
+
* The transcript has no field for the machine. The machine comes from vdx
|
|
16
|
+
* itself: `vdx ai --check`, run by the vdx plugin's SessionStart hook, names
|
|
17
|
+
* it, and Claude Code keeps the hook's text in the transcript — on every
|
|
18
|
+
* start, resume, /clear and compact. The last such line is where the
|
|
19
|
+
* conversation was last started. Derived from the synced transcript, never
|
|
20
|
+
* stored apart from it.
|
|
21
|
+
*
|
|
22
|
+
* A conversation from before vdx named the machine has no such line. For it
|
|
23
|
+
* the machine's own `<config>/history.jsonl` answers half the question: it
|
|
24
|
+
* records the conversation of every prompt typed here, and it is not synced
|
|
25
|
+
* (the owner's Syncthing ignores it; unsynced setups have nothing to sync). A
|
|
26
|
+
* conversation typed in here is this machine's; one never typed in here is
|
|
27
|
+
* another machine's — which one, nothing says.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The opening of `vdx ai --check`'s text; the label is an ssh destination from the other machines. */
|
|
31
|
+
export const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
|
|
32
|
+
const MACHINE_LINE_RE = /^vdx ai: this machine is `([^`]+)`/m;
|
|
33
|
+
/** A label read from a synced file goes to ssh: it must not be able to read as an option. */
|
|
34
|
+
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
35
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
|
+
|
|
37
|
+
export function machineLine(label: string): string {
|
|
38
|
+
return `${MACHINE_LINE_PREFIX} \`${label}\``;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isConversationId(s: string): boolean {
|
|
42
|
+
return ID_RE.test(s);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Conversation {
|
|
46
|
+
id: string;
|
|
47
|
+
/** Last write — the file's mtime, which Syncthing carries over from the writer. */
|
|
48
|
+
updated: Date;
|
|
49
|
+
/** Where it was last started; null for a conversation from before vdx named the machine. */
|
|
50
|
+
machine: string | null;
|
|
51
|
+
/** A prompt was typed in it on this machine (history.jsonl). */
|
|
52
|
+
typedHere: boolean;
|
|
53
|
+
/** Claude's title for it, else the last prompt. */
|
|
54
|
+
title: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function claudeConfigDir(env: NodeJS.ProcessEnv, home: string): string {
|
|
58
|
+
const dir = env['CLAUDE_CONFIG_DIR']?.trim();
|
|
59
|
+
return dir ? path.resolve(dir.replace(/^~(?=$|\/)/, home)) : path.join(home, '.claude');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function projectDirName(projectRoot: string): string {
|
|
63
|
+
return projectRoot.replace(/[^A-Za-z0-9]/g, '-');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read one transcript; null when nobody ever wrote in it — there is nothing to continue. */
|
|
67
|
+
export function readConversation(file: string, updated: Date): Conversation | null {
|
|
68
|
+
let text: string;
|
|
69
|
+
try {
|
|
70
|
+
text = fs.readFileSync(file, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let machine: string | null = null;
|
|
75
|
+
let aiTitle: string | null = null;
|
|
76
|
+
let lastPrompt: string | null = null;
|
|
77
|
+
let turns = false;
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
// Cheap filters first: transcripts run to megabytes, and only these lines matter.
|
|
80
|
+
const isHook = line.includes('"hook_additional_context"') && line.includes(MACHINE_LINE_PREFIX);
|
|
81
|
+
const isTitle = line.includes('"ai-title"') || line.includes('"last-prompt"');
|
|
82
|
+
if (!turns && line.includes('"type":"user"')) turns = true;
|
|
83
|
+
if (!isHook && !isTitle) continue;
|
|
84
|
+
let entry: any;
|
|
85
|
+
try {
|
|
86
|
+
entry = JSON.parse(line);
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (entry?.type === 'ai-title' && typeof entry.aiTitle === 'string') aiTitle = entry.aiTitle;
|
|
91
|
+
else if (entry?.type === 'last-prompt' && typeof entry.lastPrompt === 'string') lastPrompt = entry.lastPrompt;
|
|
92
|
+
else if (entry?.type === 'attachment' && entry.attachment?.type === 'hook_additional_context') {
|
|
93
|
+
const content = entry.attachment.content;
|
|
94
|
+
for (const part of Array.isArray(content) ? content : [content]) {
|
|
95
|
+
const label = typeof part === 'string' ? part.match(MACHINE_LINE_RE)?.[1] : undefined;
|
|
96
|
+
if (label && LABEL_RE.test(label)) machine = label;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!turns) return null;
|
|
101
|
+
const id = path.basename(file, '.jsonl');
|
|
102
|
+
return { id, updated, machine, typedHere: false, title: aiTitle ?? lastPrompt };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Which of `ids` had a prompt typed in them on this machine. */
|
|
106
|
+
export function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
|
|
107
|
+
const found = new Set<string>();
|
|
108
|
+
let text: string;
|
|
109
|
+
try {
|
|
110
|
+
text = fs.readFileSync(path.join(claudeConfigDir(env, home), 'history.jsonl'), 'utf8');
|
|
111
|
+
} catch {
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
for (const m of text.matchAll(/"sessionId":"([0-9a-f-]{36})"/g)) {
|
|
115
|
+
if (ids.has(m[1]!)) found.add(m[1]!);
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The project's conversations, newest first; at most `limit` files are read. */
|
|
121
|
+
export function listConversations(input: {
|
|
122
|
+
env: NodeJS.ProcessEnv;
|
|
123
|
+
home: string;
|
|
124
|
+
projectRoot: string;
|
|
125
|
+
limit?: number;
|
|
126
|
+
}): Conversation[] {
|
|
127
|
+
const roots = new Set([input.projectRoot]);
|
|
128
|
+
try {
|
|
129
|
+
roots.add(fs.realpathSync(input.projectRoot));
|
|
130
|
+
} catch {
|
|
131
|
+
/* the plain path is enough */
|
|
132
|
+
}
|
|
133
|
+
const files: { file: string; updated: Date }[] = [];
|
|
134
|
+
for (const root of roots) {
|
|
135
|
+
const dir = path.join(claudeConfigDir(input.env, input.home), 'projects', projectDirName(root));
|
|
136
|
+
let names: string[];
|
|
137
|
+
try {
|
|
138
|
+
names = fs.readdirSync(dir);
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const name of names) {
|
|
143
|
+
// `<id>.sync-conflict-….jsonl` is Syncthing's copy of a losing side, not a conversation.
|
|
144
|
+
if (!name.endsWith('.jsonl') || !isConversationId(name.slice(0, -'.jsonl'.length))) continue;
|
|
145
|
+
const file = path.join(dir, name);
|
|
146
|
+
try {
|
|
147
|
+
files.push({ file, updated: fs.statSync(file).mtime });
|
|
148
|
+
} catch {
|
|
149
|
+
/* gone since the listing */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
files.sort((a, b) => b.updated.getTime() - a.updated.getTime());
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
const out: Conversation[] = [];
|
|
156
|
+
for (const f of files.slice(0, input.limit ?? 30)) {
|
|
157
|
+
const c = readConversation(f.file, f.updated);
|
|
158
|
+
if (c && !seen.has(c.id)) {
|
|
159
|
+
seen.add(c.id);
|
|
160
|
+
out.push(c);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const typed = typedHere(input.env, input.home, seen);
|
|
164
|
+
return out.map((c) => ({ ...c, typedHere: typed.has(c.id) }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sameMachine(a: string | null, b: string | null): boolean {
|
|
168
|
+
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The machine a conversation belongs to: its last start, else this one if typed in here; null — unknown. */
|
|
172
|
+
export function machineOf(c: Conversation, host: string): string | null {
|
|
173
|
+
return c.machine ?? (c.typedHere ? host : null);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The newest conversation of each machine (unknown counts as one), newest first. */
|
|
177
|
+
export function latestPerMachine(conversations: Conversation[], machine: (c: Conversation) => string | null): Conversation[] {
|
|
178
|
+
const out: Conversation[] = [];
|
|
179
|
+
for (const c of [...conversations].sort((a, b) => b.updated.getTime() - a.updated.getTime())) {
|
|
180
|
+
if (!out.some((o) => sameMachine(machine(o), machine(c)))) out.push(c);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The conversation a running Claude Code process is in, from its session file
|
|
188
|
+
* `<config>/sessions/<pid>.json` — it follows /clear. That folder is per
|
|
189
|
+
* machine, so the pid is this machine's.
|
|
190
|
+
*/
|
|
191
|
+
export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: number): string | null {
|
|
192
|
+
try {
|
|
193
|
+
const data = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'));
|
|
194
|
+
const id = data?.sessionId;
|
|
195
|
+
return typeof id === 'string' && isConversationId(id) ? id : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A Claude Code process running right now, and the conversation it is in. */
|
|
202
|
+
export interface LiveSession {
|
|
203
|
+
pid: number;
|
|
204
|
+
conversation: string;
|
|
205
|
+
/** `session:@window.%pane`, when it runs in tmux. */
|
|
206
|
+
tmux: string | null;
|
|
207
|
+
/** Claude Code's own word for it: `busy`, `waiting`, … */
|
|
208
|
+
status: string | null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Session files, one JSON object per line — a file read here, or the output of LIVE_SESSIONS_SCRIPT. */
|
|
212
|
+
export function parseLiveSessions(text: string): LiveSession[] {
|
|
213
|
+
const out: LiveSession[] = [];
|
|
214
|
+
for (const line of text.split('\n')) {
|
|
215
|
+
if (!line.trim().startsWith('{')) continue;
|
|
216
|
+
let data: any;
|
|
217
|
+
try {
|
|
218
|
+
data = JSON.parse(line);
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId)) continue;
|
|
223
|
+
out.push({
|
|
224
|
+
pid: data.pid,
|
|
225
|
+
conversation: data.sessionId,
|
|
226
|
+
tmux: typeof data.tmux === 'string' ? data.tmux : null,
|
|
227
|
+
status: typeof data.status === 'string' ? data.status : null,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function pidAlive(pid: number): boolean {
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid, 0);
|
|
236
|
+
return true;
|
|
237
|
+
} catch (e: any) {
|
|
238
|
+
return e?.code === 'EPERM';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Prints the session file of every Claude Code process alive on the machine it
|
|
244
|
+
* runs on, one per line — what `vdx ai` asks another machine over ssh. Claude
|
|
245
|
+
* removes the file of a process that ended, but not after a crash or a reboot:
|
|
246
|
+
* the pid is checked. `<pid>.sync-conflict-….json` is not a session file.
|
|
247
|
+
*/
|
|
248
|
+
export const LIVE_SESSIONS_SCRIPT =
|
|
249
|
+
'd="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions"; for f in "$d"/*.json; do ' +
|
|
250
|
+
'p=${f##*/}; p=${p%.json}; case $p in ""|*[!0-9]*) continue;; esac; ' +
|
|
251
|
+
'kill -0 "$p" 2>/dev/null && tr -d "\\n" < "$f" && echo; done; exit 0';
|
|
252
|
+
|
|
253
|
+
/** Claude Code processes alive on this machine. */
|
|
254
|
+
export function liveSessionsHere(env: NodeJS.ProcessEnv, home: string, alive: (pid: number) => boolean = pidAlive): LiveSession[] {
|
|
255
|
+
const dir = path.join(claudeConfigDir(env, home), 'sessions');
|
|
256
|
+
let names: string[];
|
|
257
|
+
try {
|
|
258
|
+
names = fs.readdirSync(dir);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const lines: string[] = [];
|
|
263
|
+
for (const name of names) {
|
|
264
|
+
const pid = name.match(/^(\d+)\.json$/)?.[1];
|
|
265
|
+
if (!pid || !alive(Number(pid))) continue;
|
|
266
|
+
try {
|
|
267
|
+
lines.push(fs.readFileSync(path.join(dir, name), 'utf8').replace(/\n/g, ''));
|
|
268
|
+
} catch {
|
|
269
|
+
/* ended since the listing */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return parseLiveSessions(lines.join('\n'));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for a person choosing. */
|
|
276
|
+
export function describeLive(machine: string, live: LiveSession, here: boolean): string {
|
|
277
|
+
const where = live.tmux ? live.tmux.replace(/:.*$/, '') : `pid ${live.pid}`;
|
|
278
|
+
return `running ${here ? 'here' : `on ${machine}`} now (${where}${live.status ? `, ${live.status}` : ''})`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** "3 min ago", "yesterday 14:05", "2026-09-30 14:05" — for a person choosing. */
|
|
282
|
+
export function describeAge(when: Date, now: Date = new Date()): string {
|
|
283
|
+
const min = Math.round((now.getTime() - when.getTime()) / 60_000);
|
|
284
|
+
if (min < 1) return 'just now';
|
|
285
|
+
if (min < 60) return `${min} min ago`;
|
|
286
|
+
const hh = String(when.getHours()).padStart(2, '0');
|
|
287
|
+
const mm = String(when.getMinutes()).padStart(2, '0');
|
|
288
|
+
const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
289
|
+
const days = Math.round((day(now) - day(when)) / 86_400_000);
|
|
290
|
+
if (days === 0) return `today ${hh}:${mm}`;
|
|
291
|
+
if (days === 1) return `yesterday ${hh}:${mm}`;
|
|
292
|
+
const date = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')}`;
|
|
293
|
+
return `${date} ${hh}:${mm}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function describeConversation(c: Conversation, machine: string | null, now: Date = new Date()): string {
|
|
297
|
+
const title = c.title ? ` «${c.title.replace(/\s+/g, ' ').trim().slice(0, 60)}»` : '';
|
|
298
|
+
return `${machine ?? '?'} ${describeAge(c.updated, now)} ${c.id.slice(0, 8)}${title}`;
|
|
299
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,13 +23,16 @@ 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';
|
|
31
33
|
import { resolveDefaultRubric } from './defaults.ts';
|
|
32
34
|
import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
|
|
35
|
+
import { isConversationId } from './conversations.ts';
|
|
33
36
|
|
|
34
37
|
const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
35
38
|
|
|
@@ -37,9 +40,9 @@ const USAGE = `Usage:
|
|
|
37
40
|
vdx <up|down|build|test|check|fix> run lifecycle verb (via mise run <verb>)
|
|
38
41
|
vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json] (default: cwd)
|
|
39
42
|
vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
|
|
40
|
-
vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
43
|
+
vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
|
|
41
44
|
vdx doctor [--format=ansi|markdown|json] [--json]
|
|
42
|
-
vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run]
|
|
45
|
+
vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
|
|
43
46
|
vdx <command> --help what the command does; an unknown option is refused (exit 2)
|
|
44
47
|
vdx --version
|
|
45
48
|
`;
|
|
@@ -73,6 +76,8 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
73
76
|
project_path the project (default: cwd); its git toplevel is used
|
|
74
77
|
@host the same command on <host> over ssh: its vdx, profile and tmux
|
|
75
78
|
--new a new conversation instead of continuing the last one
|
|
79
|
+
--conversation <id>
|
|
80
|
+
continue this Claude Code conversation
|
|
76
81
|
--restart restart the running agent in its pane — applies a changed profile
|
|
77
82
|
--detach start without attaching; print the command that attaches
|
|
78
83
|
--dry-run print the plan and the project's running agents; start nothing
|
|
@@ -81,6 +86,14 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
81
86
|
SessionStart hook); prints nothing without a profile; exit 3 on drift
|
|
82
87
|
-h, --help this help
|
|
83
88
|
|
|
89
|
+
Which conversation a start continues (Claude Code): each one is marked with the
|
|
90
|
+
machine it was last started on (the vdx plugin's hook writes it in), and the
|
|
91
|
+
other machines are asked over ssh which ones run there now. Another machine's
|
|
92
|
+
conversation is continued on that machine; if it does not answer, vdx asks
|
|
93
|
+
before continuing it here. When the choice is not plain, vdx lists them, the
|
|
94
|
+
running ones marked — Enter takes the newest; without a terminal it takes this
|
|
95
|
+
machine's.
|
|
96
|
+
|
|
84
97
|
Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
|
|
85
98
|
3 the running agent lacks profile flags (rerun with --restart); 4 the start
|
|
86
99
|
was not confirmed.
|
|
@@ -108,12 +121,18 @@ mise.toml is overwritten only with --force.
|
|
|
108
121
|
positionals: 1,
|
|
109
122
|
},
|
|
110
123
|
publish: {
|
|
111
|
-
usage: 'vdx publish <patch|minor|major> [--dry-run] [--force]',
|
|
112
|
-
help: `Releases the library in the current directory
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
115
134
|
`,
|
|
116
|
-
bools: ['dry-run', 'force'],
|
|
135
|
+
bools: ['dry-run', 'force', 'no-push'],
|
|
117
136
|
positionals: 1,
|
|
118
137
|
},
|
|
119
138
|
doctor: {
|
|
@@ -127,10 +146,11 @@ Exit 2 when something is missing.
|
|
|
127
146
|
positionals: 0,
|
|
128
147
|
},
|
|
129
148
|
ai: {
|
|
130
|
-
usage: 'vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run]',
|
|
149
|
+
usage: 'vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run]',
|
|
131
150
|
help: AI_HELP,
|
|
132
151
|
// `--resume` asked to continue in 0.12; continuing is the default now.
|
|
133
152
|
bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
|
|
153
|
+
values: ['conversation'],
|
|
134
154
|
positionals: 1,
|
|
135
155
|
},
|
|
136
156
|
...Object.fromEntries(
|
|
@@ -308,7 +328,7 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
308
328
|
const bumpArg = opts.positionals[0];
|
|
309
329
|
if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
|
|
310
330
|
process.stderr.write(
|
|
311
|
-
'Usage: vdx publish <patch|minor|major> [--dry-run] [--force]\n',
|
|
331
|
+
'Usage: vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]\n',
|
|
312
332
|
);
|
|
313
333
|
process.exit(1);
|
|
314
334
|
}
|
|
@@ -328,15 +348,18 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
328
348
|
bump,
|
|
329
349
|
dryRun: Boolean(opts.flags['dry-run']),
|
|
330
350
|
force: Boolean(opts.flags.force),
|
|
351
|
+
push: !opts.flags['no-push'],
|
|
331
352
|
};
|
|
332
353
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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();
|
|
340
363
|
|
|
341
364
|
process.stdout.write(renderPublishPlan(plan));
|
|
342
365
|
|
|
@@ -345,13 +368,30 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
345
368
|
return;
|
|
346
369
|
}
|
|
347
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
|
+
|
|
348
388
|
if (!plan.preflightPassed && !planOpts.force) {
|
|
349
389
|
process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
|
|
350
390
|
process.exit(2);
|
|
351
391
|
}
|
|
352
392
|
|
|
353
393
|
try {
|
|
354
|
-
executePublish(plan);
|
|
394
|
+
executePublish(plan, deps);
|
|
355
395
|
} catch (e: any) {
|
|
356
396
|
process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
|
|
357
397
|
process.exit(3);
|
|
@@ -415,13 +455,24 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
415
455
|
}
|
|
416
456
|
process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
|
|
417
457
|
}
|
|
458
|
+
const conversation = typeof opts.flags.conversation === 'string' ? opts.flags.conversation : undefined;
|
|
459
|
+
if (conversation !== undefined && !isConversationId(conversation)) {
|
|
460
|
+
process.stderr.write(`vdx ai: --conversation takes a conversation id (a UUID), not "${conversation}"\n`);
|
|
461
|
+
process.exit(2);
|
|
462
|
+
}
|
|
463
|
+
if (conversation !== undefined && opts.flags.new === true) {
|
|
464
|
+
process.stderr.write('vdx ai: --new and --conversation ask for different conversations — pick one\n');
|
|
465
|
+
process.exit(2);
|
|
466
|
+
}
|
|
418
467
|
const options = {
|
|
419
468
|
path: opts.positionals[0] ?? '.',
|
|
420
469
|
restart: opts.flags.restart === true,
|
|
421
470
|
fresh: opts.flags.new === true,
|
|
471
|
+
conversation,
|
|
422
472
|
detach: opts.flags.detach === true,
|
|
423
473
|
dryRun: opts.flags['dry-run'] === true,
|
|
424
474
|
};
|
|
475
|
+
const deps = { ...defaultDeps(), version: readCliVersion() };
|
|
425
476
|
// `ai@m3`: the same command on another machine. Its own label runs here.
|
|
426
477
|
const host = opts.cmd.startsWith('ai@') ? opts.cmd.slice(3) : null;
|
|
427
478
|
if (host !== null) {
|
|
@@ -430,10 +481,10 @@ function cmdAi(opts: ParsedArgs): void {
|
|
|
430
481
|
process.exit(2);
|
|
431
482
|
}
|
|
432
483
|
if (host.toLowerCase() !== hostLabel().toLowerCase()) {
|
|
433
|
-
process.exit(runAiRemote(options, host,
|
|
484
|
+
process.exit(runAiRemote(options, host, deps));
|
|
434
485
|
}
|
|
435
486
|
}
|
|
436
|
-
process.exit(runAi(options,
|
|
487
|
+
process.exit(runAi(options, deps));
|
|
437
488
|
}
|
|
438
489
|
|
|
439
490
|
const parsed = parseArgs(process.argv);
|
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
|
}
|