@vodmal/vdx-cli 0.32.0 → 0.33.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
@@ -127,11 +127,16 @@ By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
127
127
  each CLI release). Override via `--rubric <path>` or the env var `VDX_RUBRIC`.
128
128
 
129
129
  `branch-protection` is the one axis the files cannot answer: `vdx audit` asks
130
- GitHub whether the default branch of `origin` takes changes only after a check
131
- required of everyone, admins included. A public repo needs no token; a private
132
- one takes it from `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`. Another
133
- hosting, no network or no token where one is needed leave the axis unknown,
134
- with the reason in the report; `VDX_OFFLINE=1` does not ask at all.
130
+ the hosting of `origin` whether its default branch takes changes only after a
131
+ check required of everyone, admins included. GitHub: protection or a ruleset
132
+ nobody bypasses; a public repo needs no token, a private one takes it from
133
+ `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`. GitLab (a host with `gitlab` in
134
+ its name, or one glab has a token for): nobody may push to the branch directly,
135
+ and a merge request needs a passing pipeline; the token is glab's for that
136
+ host, or `GITLAB_TOKEN` when the host is `GITLAB_HOST` (`gitlab.com` unless
137
+ set) — vdx never sends a token to a host it does not belong to. Not met comes with the reason; another hosting, no network or no token
138
+ where one is needed leave the axis unknown, with the reason too;
139
+ `VDX_OFFLINE=1` does not ask at all.
135
140
 
136
141
  ## Dev
137
142
 
@@ -172,8 +177,8 @@ npm run typecheck
172
177
  ## Not implemented yet
173
178
 
174
179
  - Above L1 the `ci` axis reads GitHub Actions only; other CI systems answer
175
- "unknown". `branch-protection` is "unknown" everywhere until a hosting
176
- extension exists.
180
+ "unknown". `branch-protection` reads GitHub and GitLab; other hostings
181
+ (gitolite, Bitbucket) answer "unknown".
177
182
  - Baseline loading from a git ref is documented but evaluator still reads file
178
183
  paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
179
184
  - Watermark drift (phase 2 of `drift-algorithm.md`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.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 CHANGED
@@ -119,11 +119,16 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
119
119
  const names = t.checks.join(', ');
120
120
  const link = `https://github.com/${t.repo}/commit/${sha}/checks`;
121
121
  const again = `once they pass: vdx push (${link})`;
122
- const ask = () => {
122
+ // No answer at all — a timeout, the network — is not a refusal: the wait asks again
123
+ // (0.32.0's release push stopped on one ETIMEDOUT at a load of ~7, 2026-10-20).
124
+ let silent: string | null = null;
125
+ const ask = (): CheckState[] | Unknown | null => {
123
126
  try {
127
+ silent = null;
124
128
  return commitChecks(d.io, t.repo, sha, t.checks, t.token);
125
129
  } catch (e) {
126
- return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
130
+ silent = `GitHub did not answer: ${errorMessage(e)}`;
131
+ return null;
127
132
  }
128
133
  };
129
134
  const toBranch = () => {
@@ -137,7 +142,7 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
137
142
  // A commit not pushed yet is not asked about — GitHub would only answer 422.
138
143
  if (d.io.git(['rev-parse', '--verify', '--quiet', `refs/remotes/${t.remote}/${gate}`]) === sha) {
139
144
  const before = ask();
140
- if (!isUnknown(before) && passed(before)) {
145
+ if (before !== null && !isUnknown(before) && passed(before)) {
141
146
  d.log(`✓ ${short} has passed ${names}`);
142
147
  toBranch();
143
148
  return;
@@ -165,6 +170,15 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
165
170
  }
166
171
  const states = ask();
167
172
  d.progress?.('');
173
+ if (states === null) {
174
+ if (d.now() - started >= d.timeoutMs) {
175
+ throw new Error(
176
+ `${silent} after ${Math.round(d.timeoutMs / 60_000)} min — ${t.branch} did not take ${short}; ${again}`,
177
+ );
178
+ }
179
+ d.log(` … ${seconds()} s — ${silent}; asking again`);
180
+ continue;
181
+ }
168
182
  if (isUnknown(states)) throw new Error(`${states.unknown} — ${t.branch} did not take ${short}; ${again}`);
169
183
  const failed = states.filter((s) => s.state === 'failed');
170
184
  if (failed.length > 0) {
package/src/hosting.ts CHANGED
@@ -4,7 +4,7 @@ import type { Ctx } from './facts.ts';
4
4
  import { at, errorMessage, list, str } from './narrow.ts';
5
5
  import type { Unknown, Verdict } from './predicates.ts';
6
6
 
7
- /** One answer of the GitHub API: its status and its body, parsed (null when not JSON). */
7
+ /** One answer of a hosting's API: its status and its body, parsed (null when not JSON). */
8
8
  export interface Answer {
9
9
  status: number;
10
10
  body: unknown;
@@ -17,7 +17,9 @@ export interface HostingIo {
17
17
  git: (args: string[]) => string | null;
18
18
  /** The token `gh` keeps; null without gh or a login. */
19
19
  ghToken: () => string | null;
20
- /** GET of an api.github.com path. Throws when no answer came at all. */
20
+ /** The token `glab` keeps for a GitLab host; null without glab or a login there. */
21
+ glabToken: (host: string) => string | null;
22
+ /** GET of an api.github.com path, or of a whole https:// URL. Throws when no answer came at all. */
21
23
  get: (apiPath: string, token: string | null) => Answer;
22
24
  }
23
25
 
@@ -26,9 +28,9 @@ export interface HostingIo {
26
28
  // environment, not its arguments — those are visible in ps.
27
29
  const GET = `
28
30
  const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' };
29
- if (process.env.VDX_GITHUB_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_GITHUB_TOKEN;
31
+ if (process.env.VDX_HOSTING_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_HOSTING_TOKEN;
30
32
  try {
31
- const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(8000) });
33
+ const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(15000) });
32
34
  process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
33
35
  } catch (e) {
34
36
  process.stdout.write(JSON.stringify({ error: String(e?.cause?.code ?? e?.cause?.message ?? e?.message ?? e) }));
@@ -37,13 +39,15 @@ try {
37
39
 
38
40
  function get(apiPath: string, token: string | null): Answer {
39
41
  const env = { ...process.env };
40
- delete env.VDX_GITHUB_TOKEN;
41
- if (token) env.VDX_GITHUB_TOKEN = token;
42
+ delete env.VDX_HOSTING_TOKEN;
43
+ if (token) env.VDX_HOSTING_TOKEN = token;
44
+ const url = apiPath.startsWith('https://') ? apiPath : `https://api.github.com${apiPath}`;
42
45
  const out: unknown = JSON.parse(
43
- execFileSync(process.execPath, ['--input-type=module', '-e', GET, `https://api.github.com${apiPath}`], {
46
+ execFileSync(process.execPath, ['--input-type=module', '-e', GET, url], {
44
47
  encoding: 'utf8',
45
48
  env,
46
- timeout: 10_000,
49
+ // node's own start counts too: at a load of ~7 on lft 10 s ran out (2026-10-20).
50
+ timeout: 20_000,
47
51
  stdio: ['ignore', 'pipe', 'ignore'],
48
52
  }),
49
53
  );
@@ -75,6 +79,7 @@ export function machineIo(root: string): HostingIo {
75
79
  env: process.env,
76
80
  git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
77
81
  ghToken: () => run('gh', ['auth', 'token']),
82
+ glabToken: (host) => run('glab', ['config', 'get', 'token', '--host', host]),
78
83
  get,
79
84
  };
80
85
  }
@@ -159,7 +164,7 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
159
164
 
160
165
  /**
161
166
  * Whether the default branch on the hosting of `origin` takes changes only after a check
162
- * required of everyone. GitHub only; what vdx cannot ask is unknown, with the reason.
167
+ * required of everyone. GitHub and GitLab; what vdx cannot ask is unknown, with the reason.
163
168
  */
164
169
  export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
165
170
  const key = 'hosting:branch-requires-checks';
@@ -171,12 +176,90 @@ export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.pro
171
176
 
172
177
  function ask(io: HostingIo): Verdict {
173
178
  const gh = githubRepo(io, 'origin');
174
- if (isUnknownRepo(gh)) return gh;
175
- try {
176
- return judge(io, gh.repo, gh.token);
177
- } catch (e) {
178
- return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
179
+ if (!isUnknownRepo(gh)) {
180
+ try {
181
+ return judge(io, gh.repo, gh.token);
182
+ } catch (e) {
183
+ return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
184
+ }
185
+ }
186
+ if (io.env.VDX_OFFLINE) return gh;
187
+ const url = io.git(['remote', 'get-url', 'origin']);
188
+ if (!url) return gh;
189
+ const { host, path: repo } = remoteParts(url);
190
+ // GitLab: the host GITLAB_HOST names, one glab has a token for (gitlab.finam.ru, DL #12),
191
+ // or one with gitlab in its name. A token goes only where it belongs: GITLAB_TOKEN to
192
+ // GITLAB_HOST, as glab sends it; glab's to its own host. Any other host is asked without
193
+ // one — an origin is the repository's to name, and may point anywhere.
194
+ const tokenHost = (io.env.GITLAB_HOST || 'gitlab.com')
195
+ .replace(/^https?:\/\//, '')
196
+ .replace(/\/.*$/, '')
197
+ .toLowerCase();
198
+ const glab = io.glabToken(host);
199
+ const token = (host.toLowerCase() === tokenHost ? io.env.GITLAB_TOKEN : undefined) || glab;
200
+ if (host.toLowerCase() === tokenHost || glab || host.includes('gitlab')) {
201
+ try {
202
+ return judgeGitlab(io, host, repo, token);
203
+ } catch (e) {
204
+ return { unknown: `${host} did not answer: ${errorMessage(e)}` };
205
+ }
206
+ }
207
+ return { unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub and GitLab` };
208
+ }
209
+
210
+ /** A refusal of a GitLab API as the reason the answer is unknown; null for a 200. */
211
+ function gitlabRefusal(a: Answer, host: string, repo: string, token: string | null): Unknown | null {
212
+ if (a.status === 200) return null;
213
+ const message = str(at(a.body, 'message')) ?? str(at(a.body, 'error')) ?? '';
214
+ if (a.status === 429) return { unknown: `${host}'s rate limit is spent` };
215
+ if ((a.status === 401 || a.status === 404) && !token) {
216
+ return {
217
+ unknown: `${host} does not show ${repo} without a token — private? glab auth login --hostname ${host}, or GITLAB_TOKEN`,
218
+ };
219
+ }
220
+ return { unknown: `${host} answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
221
+ }
222
+
223
+ /**
224
+ * GitLab: the default branch takes changes only after a pipeline when nobody may push to
225
+ * it directly — a push skips the pipeline — and a merge request needs one that passed
226
+ * (`only_allow_merge_if_pipeline_succeeds`). The agent pushes as the owner (DL #3).
227
+ */
228
+ function judgeGitlab(io: HostingIo, host: string, repo: string, token: string | null): Verdict {
229
+ const base = `https://${host}/api/v4/projects/${encodeURIComponent(repo)}`;
230
+ const p = io.get(base, token);
231
+ const refused = gitlabRefusal(p, host, repo, token);
232
+ if (refused) return refused;
233
+ const branch =
234
+ io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '') ||
235
+ str(at(p.body, 'default_branch'));
236
+ if (!branch) return { unknown: `${host} names no default branch for ${repo}` };
237
+ const pipeline = at(p.body, 'only_allow_merge_if_pipeline_succeeds') === true;
238
+
239
+ const pb = io.get(`${base}/protected_branches/${encodeURIComponent(branch)}`, token);
240
+ const isProtected = pb.status === 200;
241
+ if (!isProtected && pb.status !== 404) {
242
+ const refusedBranch = gitlabRefusal(pb, host, repo, token);
243
+ if (refusedBranch) return refusedBranch;
179
244
  }
245
+ // Who may push straight to the branch: a role above "No one" (0), a user, a group, a deploy key.
246
+ const pushers = list(at(pb.body, 'push_access_levels'))
247
+ .filter(
248
+ (l) =>
249
+ at(l, 'access_level') !== 0 ||
250
+ at(l, 'user_id') != null ||
251
+ at(l, 'group_id') != null ||
252
+ at(l, 'deploy_key_id') != null,
253
+ )
254
+ .map((l) => str(at(l, 'access_level_description')) ?? 'someone');
255
+ if (isProtected && pushers.length === 0 && pipeline) return true;
256
+
257
+ const why: string[] = [];
258
+ if (!isProtected) why.push(`${branch} is not protected`);
259
+ else if (pushers.length > 0)
260
+ why.push(`${branch} takes direct pushes from ${pushers.join(', ')} — a push skips the pipeline`);
261
+ if (!pipeline) why.push(`merge requests into ${branch} do not need a passing pipeline`);
262
+ return { no: why.join('; ') };
180
263
  }
181
264
 
182
265
  /** A remote's repository on GitHub and the token to ask with; unknown, with the reason, when vdx cannot ask. */
package/src/index.ts CHANGED
@@ -127,9 +127,12 @@ const COMMANDS: Record<string, CommandSpec> = {
127
127
  usage: 'vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json]',
128
128
  help: `Scores the project (default: cwd) against the rubric and prints the report.
129
129
  Reads only. The rubric is the bundled one unless --rubric or $VDX_RUBRIC names another.
130
- For branch-protection it asks GitHub whether the default branch of origin requires
131
- a check, with a token from $GH_TOKEN, $GITHUB_TOKEN or gh when there is one;
132
- VDX_OFFLINE=1 does not ask, and the axis says unknown.
130
+ For branch-protection it asks the hosting of origin whether its default branch
131
+ takes changes only after a check required of everyone: GitHub, with a token from
132
+ $GH_TOKEN, $GITHUB_TOKEN or gh when there is one; GitLab, with glab's token for that host,
133
+ or $GITLAB_TOKEN when the host is $GITLAB_HOST (gitlab.com unless set) — no
134
+ other host gets it. Another hosting is unknown; VDX_OFFLINE=1 does not ask, and the axis says
135
+ unknown.
133
136
  `,
134
137
  bools: ['json'],
135
138
  values: ['rubric', 'stack', 'format'],