@vodmal/vdx-cli 0.29.0 → 0.31.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 CHANGED
@@ -45,6 +45,9 @@ vdx init [project-path] [--baseline github.com/org/repo@vX.Y] [--stack <id>] [-
45
45
  # Publish a library (Node MVP; PHP/Python coming in Y.3)
46
46
  vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
47
47
 
48
+ # Push the branch; one that requires checks on GitHub takes the commit once they pass
49
+ vdx push [project-path] [--dry-run] # default: cwd
50
+
48
51
  # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project)
49
52
  vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
50
53
  vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
@@ -88,6 +91,17 @@ vdx ai
88
91
  vdx ai@m3
89
92
  ```
90
93
 
94
+ `vdx push` pushes the current branch to its upstream. When the upstream is on
95
+ GitHub and requires checks (branch protection or a ruleset), the commit goes to
96
+ `vdx-gate/<branch>` first; CI checks it there — its workflow has to run on
97
+ `vdx-gate/**` — and vdx waits for the checks, then pushes the same commit to the
98
+ branch with its annotated tags. A failed check leaves the branch as it was and
99
+ names the check. Without required checks, or off GitHub, it is a plain
100
+ `git push`. Its pushes through the checks carry `VDX_GATE_PUSH=1`, so a pre-push
101
+ hook that runs what CI runs can step aside. `vdx publish` sends its release
102
+ commit the same way, after `npm publish`, and then refuses local commits not
103
+ pushed yet: npm would get them before CI.
104
+
91
105
  `vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
92
106
  `~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
93
107
  [docs/specs/environment-format.md](../docs/specs/environment-format.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.29.0",
3
+ "version": "0.31.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/gate.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `vdx push`: a commit reaches a branch that requires checks only once they pass
3
+ * (DL #8–#10 of docs/tasks/hosting-extension). It goes to a gate branch of its own,
4
+ * CI checks it there, and the branch takes the same commit — the same SHA, a linear
5
+ * history. A branch that requires nothing, a remote not on GitHub, a hosting that
6
+ * does not answer: a plain `git push`, as before.
7
+ */
8
+ import { execFileSync } from 'node:child_process';
9
+ import { commitChecks, githubRepo, machineIo, requiredChecks, type CheckState, type HostingIo } from './hosting.ts';
10
+ import { errorMessage } from './narrow.ts';
11
+ import type { Unknown } from './predicates.ts';
12
+
13
+ /** The branch CI checks a commit on before `<branch>` takes it: `vdx-gate/main`. */
14
+ export const GATE_PREFIX = 'vdx-gate/';
15
+ /** Set for the pushes `vdx push` makes: a pre-push hook that runs what CI runs may step aside. */
16
+ const GATE_ENV = 'VDX_GATE_PUSH';
17
+
18
+ /** Where a push goes, and through what. */
19
+ export interface GateTarget {
20
+ remote: string;
21
+ branch: string;
22
+ repo: string;
23
+ token: string | null;
24
+ /** What the branch requires — GitHub's names. */
25
+ checks: string[];
26
+ }
27
+
28
+ export type GatePlan =
29
+ | { kind: 'gate'; target: GateTarget }
30
+ /** Nothing to wait for: `git push` as it is. */
31
+ | { kind: 'plain'; why: string }
32
+ | { kind: 'refuse'; message: string };
33
+
34
+ function isUnknown(v: object): v is Unknown {
35
+ return 'unknown' in v;
36
+ }
37
+
38
+ /** The current branch, its upstream and what the upstream requires — nothing is fetched. */
39
+ export function planGate(io: HostingIo): GatePlan {
40
+ const local = io.git(['symbolic-ref', '--quiet', '--short', 'HEAD']);
41
+ if (!local) return { kind: 'refuse', message: 'HEAD is not on a branch — nothing to push it to' };
42
+ const remote = io.git(['config', '--get', `branch.${local}.remote`]);
43
+ const merge = io.git(['config', '--get', `branch.${local}.merge`]);
44
+ if (!remote || !merge?.startsWith('refs/heads/')) {
45
+ return { kind: 'refuse', message: `${local} has no upstream — git push -u <remote> ${local}` };
46
+ }
47
+ const branch = merge.slice('refs/heads/'.length);
48
+ const gh = githubRepo(io, remote);
49
+ if (isUnknown(gh)) return { kind: 'plain', why: gh.unknown };
50
+ let checks: string[] | Unknown;
51
+ try {
52
+ checks = requiredChecks(io, gh.repo, branch, gh.token);
53
+ } catch (e) {
54
+ return { kind: 'plain', why: `GitHub did not answer: ${errorMessage(e)}` };
55
+ }
56
+ if (isUnknown(checks)) return { kind: 'plain', why: checks.unknown };
57
+ if (checks.length === 0) return { kind: 'plain', why: `${remote}/${branch} requires no checks` };
58
+ const behind = parseInt(io.git(['rev-list', '--count', 'HEAD..@{u}']) ?? '0', 10) || 0;
59
+ if (behind > 0) {
60
+ return {
61
+ kind: 'refuse',
62
+ message: `${remote}/${branch} has ${behind} commit(s) this branch lacks — pull first; CI would check a commit ${branch} cannot take`,
63
+ };
64
+ }
65
+ return { kind: 'gate', target: { remote, branch, repo: gh.repo, token: gh.token, checks } };
66
+ }
67
+
68
+ /** What a gated push does outside — the machine's own below, a fake in the tests. */
69
+ export interface GateDeps {
70
+ io: HostingIo;
71
+ /** `git <args>` with `VDX_GATE_PUSH=1`, its output on this terminal; throws on a non-zero exit. */
72
+ push: (args: string[]) => void;
73
+ log: (line: string) => void;
74
+ /** Rewrites one status line in place (a terminal); without it the wait logs a line now and then. */
75
+ progress?: (text: string) => void;
76
+ sleep: (ms: number) => void;
77
+ now: () => number;
78
+ /** How often GitHub is asked: its limit is 60 an hour without a token. */
79
+ pollMs: number;
80
+ timeoutMs: number;
81
+ }
82
+
83
+ function sleepSync(ms: number): void {
84
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
85
+ }
86
+
87
+ export function defaultGateDeps(root: string): GateDeps {
88
+ return {
89
+ io: machineIo(root),
90
+ push: (args) => {
91
+ execFileSync('git', ['-C', root, ...args], { stdio: 'inherit', env: { ...process.env, [GATE_ENV]: '1' } });
92
+ },
93
+ log: (line) => process.stderr.write(line + '\n'),
94
+ progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
95
+ sleep: sleepSync,
96
+ now: Date.now,
97
+ pollMs: 15_000,
98
+ timeoutMs: 20 * 60_000,
99
+ };
100
+ }
101
+
102
+ const passed = (states: CheckState[]) => states.every((s) => s.state === 'passed');
103
+ const brief = (states: CheckState[]) => states.map((s) => `${s.name}: ${s.detail.replace(/_/g, ' ')}`).join(', ');
104
+
105
+ /**
106
+ * HEAD to the target branch through its checks: the gate branch, the wait, then the
107
+ * branch itself, with the annotated tags on the way (`--follow-tags` — a release's
108
+ * tag). Throws when the branch did not take the commit; the reason names what next.
109
+ */
110
+ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
111
+ const sha = d.io.git(['rev-parse', 'HEAD']);
112
+ if (!sha) throw new Error('git rev-parse HEAD failed');
113
+ const short = sha.slice(0, 7);
114
+ const where = `${t.remote}/${t.branch}`;
115
+ if (d.io.git(['rev-parse', '@{u}']) === sha) {
116
+ d.log(`✓ ${where} is at ${short} already — nothing to push`);
117
+ return;
118
+ }
119
+ const names = t.checks.join(', ');
120
+ const link = `https://github.com/${t.repo}/commit/${sha}/checks`;
121
+ const again = `once they pass: vdx push (${link})`;
122
+ const ask = () => {
123
+ try {
124
+ return commitChecks(d.io, t.repo, sha, t.checks, t.token);
125
+ } catch (e) {
126
+ return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
127
+ }
128
+ };
129
+ const toBranch = () => {
130
+ d.log(`→ git push --follow-tags ${t.remote} ${short}:${t.branch}`);
131
+ d.push(['push', '--follow-tags', t.remote, `${sha}:refs/heads/${t.branch}`]);
132
+ d.log(`✓ ${where} is at ${short}`);
133
+ };
134
+
135
+ // Pushed once and checked: the branch takes it now.
136
+ const before = ask();
137
+ if (!isUnknown(before) && passed(before)) {
138
+ d.log(`✓ ${short} has passed ${names}`);
139
+ toBranch();
140
+ return;
141
+ }
142
+
143
+ const gate = GATE_PREFIX + t.branch;
144
+ d.log(`→ git push --force ${t.remote} ${short}:${gate} — ${where} requires ${names}; CI checks the commit there`);
145
+ d.push(['push', '--force', t.remote, `${sha}:refs/heads/${gate}`]);
146
+ d.log(`→ waiting for ${names} on ${short} (asking every ${Math.round(d.pollMs / 1000)} s): ${link}`);
147
+
148
+ const started = d.now();
149
+ const seconds = () => Math.round((d.now() - started) / 1000);
150
+ let lastLogged = 0;
151
+ let shown = '';
152
+ for (;;) {
153
+ // Between the asks, count the seconds on one line: a silent wait looks hung.
154
+ const next = d.now() + d.pollMs;
155
+ while (d.now() < next) {
156
+ if (d.progress) d.progress(` … ${seconds()} s${shown}`);
157
+ else if (seconds() - lastLogged >= 30) {
158
+ lastLogged = seconds();
159
+ d.log(` … ${lastLogged} s${shown}`);
160
+ }
161
+ d.sleep(1_000);
162
+ }
163
+ const states = ask();
164
+ d.progress?.('');
165
+ if (isUnknown(states)) throw new Error(`${states.unknown} — ${t.branch} did not take ${short}; ${again}`);
166
+ const failed = states.filter((s) => s.state === 'failed');
167
+ if (failed.length > 0) {
168
+ const which = failed.map((s) => `${s.name} — ${s.detail}${s.url ? ` (${s.url})` : ''}`).join('; ');
169
+ throw new Error(`${which}: ${t.branch} did not take ${short}`);
170
+ }
171
+ if (passed(states)) {
172
+ d.log(`✓ ${names} passed (${seconds()} s)`);
173
+ toBranch();
174
+ return;
175
+ }
176
+ if (d.now() - started >= d.timeoutMs) {
177
+ throw new Error(
178
+ `${brief(states)} after ${Math.round(d.timeoutMs / 60_000)} min — ${t.branch} did not take ${short}; ${again}`,
179
+ );
180
+ }
181
+ shown = ` — ${brief(states)}`;
182
+ }
183
+ }
package/src/hooks.ts CHANGED
@@ -428,12 +428,30 @@ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = pro
428
428
  for (const body of commands) {
429
429
  for (const m of body.matchAll(GATING_VAR_RE)) {
430
430
  const name = m[1] ?? m[2];
431
- if (name && !env[name]) out.add(name);
431
+ if (name && !env[name] && !opensAnEscape(body.slice(m.index + m[0].length))) out.add(name);
432
432
  }
433
433
  }
434
434
  return [...out].sort();
435
435
  }
436
436
 
437
+ /**
438
+ * `[ -n "$VAR" ] && exit 0`, `if [ -n "$VAR" ]; then echo …; exit 0; fi`: the variable
439
+ * lets the hook step aside (`VDX_GATE_PUSH` of `vdx push`), and unset, the hook runs in
440
+ * full — no gate is a no-op. A block doing anything but saying so and exiting 0 is a gate.
441
+ */
442
+ function opensAnEscape(afterTest: string): boolean {
443
+ const m = afterTest.match(/^\s*\](?:\s*;?\s*then\b([\s\S]*?)\bfi\b|\s*&&([^\n]*))/);
444
+ const block = m?.[1] ?? m?.[2];
445
+ if (block === undefined) return false;
446
+ const statements = block
447
+ .split(/\n|;/)
448
+ .map((st) => st.trim())
449
+ .filter((st) => st !== '');
450
+ return (
451
+ statements.at(-1) === 'exit 0' && statements.every((st) => st === 'exit 0' || /^(?:echo|printf|:)(?:\s|$)/.test(st))
452
+ );
453
+ }
454
+
437
455
  /**
438
456
  * A program a command runs from this machine, outside the repository:
439
457
  * `$HOME/…`, `~/…`, `/Users/…` — quoted or bare, at the head of a command.
package/src/hosting.ts CHANGED
@@ -60,7 +60,7 @@ function get(apiPath: string, token: string | null): Answer {
60
60
  return { status, body };
61
61
  }
62
62
 
63
- function machineIo(root: string): HostingIo {
63
+ export function machineIo(root: string): HostingIo {
64
64
  const run = (cmd: string, args: string[]): string | null => {
65
65
  try {
66
66
  return (
@@ -152,17 +152,129 @@ export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.pro
152
152
  }
153
153
 
154
154
  function ask(io: HostingIo): Verdict {
155
+ const gh = githubRepo(io, 'origin');
156
+ if (isUnknownRepo(gh)) return gh;
157
+ try {
158
+ return judge(io, gh.repo, gh.token);
159
+ } catch (e) {
160
+ return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
161
+ }
162
+ }
163
+
164
+ /** A remote's repository on GitHub and the token to ask with; unknown, with the reason, when vdx cannot ask. */
165
+ export function githubRepo(io: HostingIo, remote: string): { repo: string; token: string | null } | Unknown {
155
166
  if (io.env.VDX_OFFLINE) return { unknown: 'VDX_OFFLINE is set — the hosting was not asked' };
156
- const url = io.git(['remote', 'get-url', 'origin']);
157
- if (!url) return { unknown: 'no origin remote — the hosting is not known' };
167
+ const url = io.git(['remote', 'get-url', remote]);
168
+ if (!url) return { unknown: `no ${remote} remote — the hosting is not known` };
158
169
  const { host, path: repo } = remoteParts(url);
159
170
  // The host only: a URL can carry a token.
160
171
  if (host !== 'github.com')
161
- return { unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
162
- const token = io.env.GH_TOKEN || io.env.GITHUB_TOKEN || io.ghToken();
163
- try {
164
- return judge(io, repo, token);
165
- } catch (e) {
166
- return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
172
+ return { unknown: `${remote} is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
173
+ return { repo, token: io.env.GH_TOKEN || io.env.GITHUB_TOKEN || io.ghToken() };
174
+ }
175
+
176
+ function isUnknownRepo(v: { repo: string } | Unknown): v is Unknown {
177
+ return 'unknown' in v;
178
+ }
179
+
180
+ /**
181
+ * The checks a branch requires before it takes a commit — of classic protection and of
182
+ * rulesets together, whoever they bind: what GitHub will ask of the commit.
183
+ */
184
+ export function requiredChecks(io: HostingIo, repo: string, branch: string, token: string | null): string[] | Unknown {
185
+ const ref = encodeURIComponent(branch);
186
+ const names = new Set<string>();
187
+ const b = io.get(`/repos/${repo}/branches/${ref}`, token);
188
+ const refusedBranch = refusal(b, repo, token);
189
+ if (refusedBranch) return refusedBranch;
190
+ const classic = at(b.body, 'protection', 'required_status_checks');
191
+ if (at(classic, 'enforcement_level') !== 'off') {
192
+ for (const c of list(at(classic, 'checks'))) add(names, at(c, 'context'));
193
+ for (const c of list(at(classic, 'contexts'))) add(names, c);
194
+ }
195
+ const rules = io.get(`/repos/${repo}/rules/branches/${ref}`, token);
196
+ const refusedRules = refusal(rules, repo, token);
197
+ if (refusedRules) return refusedRules;
198
+ for (const rule of list(rules.body)) {
199
+ if (at(rule, 'type') !== 'required_status_checks') continue;
200
+ for (const c of list(at(rule, 'parameters', 'required_status_checks'))) add(names, at(c, 'context'));
201
+ }
202
+ return [...names];
203
+ }
204
+
205
+ function add(names: Set<string>, name: unknown): void {
206
+ const n = str(name);
207
+ if (n) names.add(n);
208
+ }
209
+
210
+ /** Where one required check of a commit stands. */
211
+ export interface CheckState {
212
+ name: string;
213
+ state: 'pending' | 'passed' | 'failed';
214
+ /** GitHub's word for it: `in_progress`, `success`, `failure`, `not started`. */
215
+ detail: string;
216
+ url?: string;
217
+ }
218
+
219
+ /**
220
+ * The named checks of a commit: its check runs (GitHub Actions), else its commit
221
+ * statuses (an outside CI). A check that has not reported yet is pending.
222
+ */
223
+ export function commitChecks(
224
+ io: HostingIo,
225
+ repo: string,
226
+ sha: string,
227
+ names: string[],
228
+ token: string | null,
229
+ ): CheckState[] | Unknown {
230
+ const runs = io.get(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, token);
231
+ const refusedRuns = refusal(runs, repo, token);
232
+ if (refusedRuns) return refusedRuns;
233
+ // A re-run is a new check run under the same name: the newest one counts.
234
+ const latest = new Map<string, unknown>();
235
+ for (const run of list(at(runs.body, 'check_runs'))) {
236
+ const name = str(at(run, 'name'));
237
+ const id = at(run, 'id');
238
+ if (!name || typeof id !== 'number') continue;
239
+ const seen = at(latest.get(name), 'id');
240
+ if (typeof seen !== 'number' || id > seen) latest.set(name, run);
241
+ }
242
+ let statuses: Map<string, unknown> | null = null;
243
+ const states: CheckState[] = [];
244
+ for (const name of names) {
245
+ const run = latest.get(name);
246
+ if (run !== undefined) {
247
+ const status = str(at(run, 'status')) ?? 'queued';
248
+ const conclusion = str(at(run, 'conclusion')) ?? '';
249
+ const url = str(at(run, 'html_url'));
250
+ const state =
251
+ status !== 'completed'
252
+ ? 'pending'
253
+ : ['success', 'neutral', 'skipped'].includes(conclusion)
254
+ ? 'passed'
255
+ : 'failed';
256
+ states.push({ name, state, detail: status === 'completed' ? conclusion : status, ...(url ? { url } : {}) });
257
+ continue;
258
+ }
259
+ if (statuses === null) {
260
+ const combined = io.get(`/repos/${repo}/commits/${sha}/status`, token);
261
+ const refused = refusal(combined, repo, token);
262
+ if (refused) return refused;
263
+ statuses = new Map();
264
+ for (const s of list(at(combined.body, 'statuses'))) {
265
+ const context = str(at(s, 'context'));
266
+ if (context && !statuses.has(context)) statuses.set(context, s);
267
+ }
268
+ }
269
+ const status = statuses.get(name);
270
+ const word = str(at(status, 'state'));
271
+ const url = str(at(status, 'target_url'));
272
+ states.push({
273
+ name,
274
+ state: word === 'success' ? 'passed' : word === 'failure' || word === 'error' ? 'failed' : 'pending',
275
+ detail: word ?? 'not started',
276
+ ...(url ? { url } : {}),
277
+ });
167
278
  }
279
+ return states;
168
280
  }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  type PublishOptions,
35
35
  } from './publish.ts';
36
36
  import { resolveDefaultRubric } from './defaults.ts';
37
+ import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate } from './gate.ts';
37
38
  import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
38
39
  import { isConversationId } from './conversations.ts';
39
40
 
@@ -44,6 +45,7 @@ const USAGE = `Usage:
44
45
  vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json] (default: cwd)
45
46
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
46
47
  vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
48
+ vdx push [project_path] [--dry-run] (default: cwd; through the branch's required checks)
47
49
  vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json] (default: cwd)
48
50
  vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
49
51
  vdx <command> --help what the command does; an unknown option is refused (exit 2)
@@ -151,7 +153,9 @@ mise.toml is overwritten only with --force.
151
153
  the version bump in package.json and package-lock.json;
152
154
  npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
153
155
  there puts the version back);
154
- git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
156
+ git commit and tag, then \`git push --follow-tags\` to the branch's upstream —
157
+ through its checks when it requires some, as \`vdx push\` does: the pre-flight
158
+ then refuses local commits not pushed yet, npm would get them before CI;
155
159
  a wait until the registry shows the new version (up to 3 min).
156
160
  --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
157
161
  pre-flight; --no-push leaves the commit and the tag local.
@@ -159,6 +163,31 @@ pre-flight; --no-push leaves the commit and the tag local.
159
163
  bools: ['dry-run', 'force', 'no-push'],
160
164
  positionals: 1,
161
165
  },
166
+ push: {
167
+ usage: 'vdx push [project_path] [--dry-run]',
168
+ help: `Pushes the current branch to its upstream. When the upstream is on GitHub and
169
+ requires checks (branch protection or a ruleset), the commit goes to
170
+ ${GATE_PREFIX}<branch> first: CI checks it there, vdx waits for the checks and then
171
+ pushes the same commit to the branch, with its annotated tags (--follow-tags).
172
+ A failed check leaves the branch as it was and names the check. A branch that
173
+ requires no checks, a remote not on GitHub, a GitHub that does not answer: a
174
+ plain \`git push\`, hooks and all.
175
+
176
+ The pushes through the checks carry VDX_GATE_PUSH=1: a pre-push hook that runs
177
+ what CI runs may step aside. GitHub is asked with a token from $GH_TOKEN,
178
+ $GITHUB_TOKEN or gh when there is one — without one it answers 60 requests an
179
+ hour, and a push takes about ten; VDX_OFFLINE=1 does not ask.
180
+
181
+ project_path the repository (default: cwd)
182
+ --dry-run say which way the push goes; push nothing
183
+
184
+ Exit codes: 0 pushed; 2 nowhere to push it (no branch, no upstream, behind it);
185
+ 3 the branch did not take the commit — a check failed, did not finish in 20 min,
186
+ or GitHub stopped answering; a plain git push exits with git's own code.
187
+ `,
188
+ bools: ['dry-run'],
189
+ positionals: 1,
190
+ },
162
191
  doctor: {
163
192
  usage: 'vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json]',
164
193
  help: `Checks this machine for what vdx needs (Node, git, mise, npm auth, docker, the
@@ -435,6 +464,39 @@ function cmdPublish(opts: ParsedArgs): void {
435
464
  }
436
465
  }
437
466
 
467
+ function cmdPush(opts: ParsedArgs): void {
468
+ const root = path.resolve(opts.positionals[0] ?? '.');
469
+ const deps = defaultGateDeps(root);
470
+ const plan = planGate(deps.io);
471
+ if (plan.kind === 'refuse') {
472
+ process.stderr.write(`vdx push: ${plan.message}\n`);
473
+ process.exit(2);
474
+ }
475
+ if (plan.kind === 'plain') {
476
+ process.stderr.write(`→ git push — ${plan.why}\n`);
477
+ if (opts.flags['dry-run']) return;
478
+ try {
479
+ execFileSync('git', ['-C', root, 'push'], { stdio: 'inherit' });
480
+ } catch (e) {
481
+ const status = at(e, 'status');
482
+ process.exit(typeof status === 'number' ? status : 1);
483
+ }
484
+ return;
485
+ }
486
+ const t = plan.target;
487
+ process.stderr.write(
488
+ `→ ${t.remote}/${t.branch} requires ${t.checks.join(', ')}: through ${GATE_PREFIX}${t.branch}, ` +
489
+ `then ${t.branch} once they pass\n`,
490
+ );
491
+ if (opts.flags['dry-run']) return;
492
+ try {
493
+ pushThroughGate(t, deps);
494
+ } catch (e) {
495
+ process.stderr.write(`\nerror: ${errorMessage(e)}\n`);
496
+ process.exit(3);
497
+ }
498
+ }
499
+
438
500
  function cmdRun(verb: LifecycleVerb): void {
439
501
  const projectRoot = process.cwd();
440
502
  const res = resolveLifecycleVerb(projectRoot, verb);
@@ -551,6 +613,7 @@ else if (['--help', '-h', 'help'].includes(parsed.cmd)) process.stdout.write(USA
551
613
  else if (parsed.cmd === 'audit') cmdAudit(checkArgs('audit', parsed));
552
614
  else if (parsed.cmd === 'init') cmdInit(checkArgs('init', parsed));
553
615
  else if (parsed.cmd === 'publish') cmdPublish(checkArgs('publish', parsed));
616
+ else if (parsed.cmd === 'push') cmdPush(checkArgs('push', parsed));
554
617
  else if (parsed.cmd === 'doctor') cmdDoctor(checkArgs('doctor', parsed));
555
618
  else if (parsed.cmd === 'ai' || parsed.cmd.startsWith('ai@')) cmdAi(checkArgs('ai', parsed));
556
619
  else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd)) {
package/src/publish.ts CHANGED
@@ -3,6 +3,8 @@ import * as path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import type { AuditResult } from './audit.ts';
5
5
  import { stackForDir, type Ctx } from './facts.ts';
6
+ import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate, type GatePlan, type GateTarget } from './gate.ts';
7
+ import { machineIo } from './hosting.ts';
6
8
  import { at, dict, errorMessage, stderrOf, str } from './narrow.ts';
7
9
 
8
10
  export type BumpKind = 'patch' | 'minor' | 'major';
@@ -34,6 +36,8 @@ export interface PushPlan {
34
36
  ahead: number;
35
37
  /** Upstream commits not here: the push would be refused. */
36
38
  behind: number;
39
+ /** The upstream requires checks: the release commit goes through them (`vdx push`). */
40
+ gate?: GateTarget;
37
41
  }
38
42
 
39
43
  export interface PublishPlan {
@@ -226,6 +230,7 @@ export function planPublish(
226
230
  ctx: Ctx,
227
231
  npm: NpmRunner = runNpm,
228
232
  git: GitRunner = runGit,
233
+ gate: (projectRoot: string) => GatePlan = (root) => planGate(machineIo(root)),
229
234
  ): PublishPlan {
230
235
  if (checkMiseOverride(opts.projectRoot)) {
231
236
  return {
@@ -339,6 +344,10 @@ export function planPublish(
339
344
  }
340
345
 
341
346
  const push = planPush(opts.projectRoot, opts.push ?? true, git);
347
+ if (push.enabled && push.upstream) {
348
+ const g = gate(opts.projectRoot);
349
+ if (g.kind === 'gate') push.gate = g.target;
350
+ }
342
351
  if (push.enabled && push.upstream && push.behind > 0) {
343
352
  preflight.push({
344
353
  name: 'push-target',
@@ -347,13 +356,25 @@ export function planPublish(
347
356
  `${push.upstream} has ${push.behind} commit(s) this branch lacks — the push after npm publish ` +
348
357
  `would be refused. Pull first (or --no-push)`,
349
358
  });
359
+ } else if (push.gate && push.ahead > 0) {
360
+ // npm publish comes before the push (DL #9 of docs/tasks/hosting-extension): what
361
+ // CI has not seen would reach npm first.
362
+ preflight.push({
363
+ name: 'push-target',
364
+ ok: false,
365
+ message:
366
+ `${push.ahead} local commit(s) are not on ${push.upstream}, which requires ${push.gate.checks.join(', ')}: ` +
367
+ `npm would get them before CI checks them. Push them first: vdx push`,
368
+ });
350
369
  } else if (push.enabled) {
351
370
  preflight.push({
352
371
  name: 'push-target',
353
372
  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',
373
+ message: push.gate
374
+ ? `OK (${push.upstream}, through ${push.gate.checks.join(', ')})`
375
+ : push.upstream
376
+ ? `OK (${push.upstream}${push.ahead ? `, ${push.ahead} local commit(s) go with the release` : ''})`
377
+ : 'no upstream for this branch — the release commit and tag stay local',
357
378
  });
358
379
  }
359
380
 
@@ -394,6 +415,8 @@ export interface PublishDeps {
394
415
  registryWaitMs: number;
395
416
  /** Rewrites one status line in place (a terminal); without it the wait logs a line now and then. */
396
417
  progress?: (text: string) => void;
418
+ /** HEAD through the upstream's checks, with the tag (`vdx push`); throws when the upstream did not take it. */
419
+ gatePush: (target: GateTarget, projectRoot: string) => void;
397
420
  }
398
421
 
399
422
  function sleepSync(ms: number): void {
@@ -410,6 +433,7 @@ export function defaultPublishDeps(): PublishDeps {
410
433
  sleep: sleepSync,
411
434
  registryWaitMs: 180_000,
412
435
  progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
436
+ gatePush: (target, projectRoot) => pushThroughGate(target, defaultGateDeps(projectRoot)),
413
437
  };
414
438
  }
415
439
 
@@ -480,6 +504,7 @@ export function waitForRegistry(name: string, version: string, deps: PublishDeps
480
504
  * is recoverable manually).
481
505
  * 4. `git push --follow-tags` to the branch's upstream, unless `--no-push`
482
506
  * or there is none. The pre-flight refused a branch behind its upstream.
507
+ * An upstream that requires checks takes the commit through them (`vdx push`).
483
508
  * 5. Wait until the registry shows the version.
484
509
  *
485
510
  * On npm publish failure or interruption (Ctrl-C at its prompt) → restore the original files.
@@ -541,7 +566,18 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
541
566
 
542
567
  // 4. push
543
568
  const push = plan.push;
544
- if (push?.enabled && push.upstream) {
569
+ if (push?.enabled && push.gate) {
570
+ try {
571
+ deps.gatePush(push.gate, plan.projectRoot);
572
+ } catch (e) {
573
+ deps.log(
574
+ `✗ ${errorMessage(e)} — ${plan.packageName}@${plan.newVersion} is published and tagged here; ` +
575
+ `once the checks pass: vdx push`,
576
+ );
577
+ throw new Error(`${push.upstream} did not take the release commit`, { cause: e });
578
+ }
579
+ deps.log(`✓ pushed to ${push.upstream} with tag v${plan.newVersion}, through ${push.gate.checks.join(', ')}`);
580
+ } else if (push?.enabled && push.upstream) {
545
581
  deps.log(`→ git push --follow-tags (${push.upstream})...`);
546
582
  try {
547
583
  deps.run('git', ['push', '--follow-tags'], plan.projectRoot);
@@ -616,9 +652,11 @@ export function renderPublishPlan(plan: PublishPlan): string {
616
652
  lines.push(
617
653
  !push?.enabled
618
654
  ? '5. Not pushed (`--no-push`): `git push --follow-tags` is yours'
619
- : push.upstream
620
- ? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
621
- : '5. Not pushed: the branch has no upstream',
655
+ : push.gate
656
+ ? `5. Through CI (\`vdx push\`): \`${GATE_PREFIX}${push.gate.branch}\`, then ${push.upstream} with the tag once ${push.gate.checks.join(', ')} pass`
657
+ : push.upstream
658
+ ? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
659
+ : '5. Not pushed: the branch has no upstream',
622
660
  );
623
661
  lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 3 min)`);
624
662
  } else {