@vodmal/vdx-cli 0.32.1 → 0.34.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,25 @@ 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. Bitbucket
138
+ Server (a host `BITBUCKET_HOST` names, one with `bitbucket` in its name, or one
139
+ git keeps an https login for — and that says it is Bitbucket): a restriction
140
+ lets nothing into the branch but a pull request and exempts nobody, and a pull
141
+ request needs a successful build ("Minimum successful builds"); the login is
142
+ the one git keeps for that host (`git credential fill`, never a prompt — log in
143
+ once with `git ls-remote https://<host>/scm/<project>/<repo>.git`, a personal
144
+ access token as the password), or `BITBUCKET_TOKEN` when the host is
145
+ `BITBUCKET_HOST`. Branch restrictions are visible to a repository admin only.
146
+ Not met comes with the reason; another hosting, no network or no token
147
+ where one is needed leave the axis unknown, with the reason too;
148
+ `VDX_OFFLINE=1` does not ask at all.
135
149
 
136
150
  ## Dev
137
151
 
@@ -172,8 +186,9 @@ npm run typecheck
172
186
  ## Not implemented yet
173
187
 
174
188
  - 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.
189
+ "unknown". `branch-protection` reads GitHub, GitLab and Bitbucket Server;
190
+ other hostings (gitolite, Bitbucket Cloud) answer "unknown", and so do
191
+ Bitbucket restrictions by pattern or branching model.
177
192
  - Baseline loading from a git ref is documented but evaluator still reads file
178
193
  paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
179
194
  - 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.1",
3
+ "version": "0.34.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/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,16 +17,28 @@ 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. */
21
- get: (apiPath: string, token: string | null) => Answer;
20
+ /** The token `glab` keeps for a GitLab host; null without glab or a login there. */
21
+ glabToken: (host: string) => string | null;
22
+ /** The login git keeps for https://<host> (`git credential fill`), never by a prompt; null without one. */
23
+ gitCredential: (host: string) => { username: string; password: string } | null;
24
+ /**
25
+ * GET of an api.github.com path, or of a whole https:// URL, with the token as `Bearer`
26
+ * or `Basic`. Throws when no answer came at all.
27
+ */
28
+ get: (apiPath: string, token: string | null, scheme?: 'Bearer' | 'Basic') => Answer;
22
29
  }
23
30
 
24
31
  // A child node runs the request: the audit is synchronous and fetch is not
25
32
  // (DL #6 of docs/tasks/hosting-extension). The token travels in its
26
- // environment, not its arguments — those are visible in ps.
33
+ // environment, not its arguments — those are visible in ps. GitHub's media
34
+ // type only to GitHub: Bitbucket answers it with 406 (DL #17).
27
35
  const GET = `
28
- 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;
36
+ const headers = new URL(process.argv[1]).host === 'api.github.com'
37
+ ? { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' }
38
+ : { accept: 'application/json', 'user-agent': 'vdx' };
39
+ if (process.env.VDX_HOSTING_TOKEN) {
40
+ headers.authorization = (process.env.VDX_HOSTING_SCHEME || 'Bearer') + ' ' + process.env.VDX_HOSTING_TOKEN;
41
+ }
30
42
  try {
31
43
  const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(15000) });
32
44
  process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
@@ -35,12 +47,14 @@ try {
35
47
  }
36
48
  `;
37
49
 
38
- function get(apiPath: string, token: string | null): Answer {
50
+ function get(apiPath: string, token: string | null, scheme: 'Bearer' | 'Basic' = 'Bearer'): Answer {
39
51
  const env = { ...process.env };
40
- delete env.VDX_GITHUB_TOKEN;
41
- if (token) env.VDX_GITHUB_TOKEN = token;
52
+ delete env.VDX_HOSTING_TOKEN;
53
+ delete env.VDX_HOSTING_SCHEME;
54
+ if (token) Object.assign(env, { VDX_HOSTING_TOKEN: token, VDX_HOSTING_SCHEME: scheme });
55
+ const url = apiPath.startsWith('https://') ? apiPath : `https://api.github.com${apiPath}`;
42
56
  const out: unknown = JSON.parse(
43
- execFileSync(process.execPath, ['--input-type=module', '-e', GET, `https://api.github.com${apiPath}`], {
57
+ execFileSync(process.execPath, ['--input-type=module', '-e', GET, url], {
44
58
  encoding: 'utf8',
45
59
  env,
46
60
  // node's own start counts too: at a load of ~7 on lft 10 s ran out (2026-10-20).
@@ -76,6 +90,26 @@ export function machineIo(root: string): HostingIo {
76
90
  env: process.env,
77
91
  git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
78
92
  ghToken: () => run('gh', ['auth', 'token']),
93
+ glabToken: (host) => run('glab', ['config', 'get', 'token', '--host', host]),
94
+ gitCredential: (host) => {
95
+ if (!/^[a-z0-9.-]+$/i.test(host)) return null;
96
+ try {
97
+ const out = execFileSync('git', ['--no-optional-locks', '-C', root, 'credential', 'fill'], {
98
+ encoding: 'utf8',
99
+ input: `protocol=https\nhost=${host}\n\n`,
100
+ // Without a login git would ask for one: no terminal, no askpass program, no window.
101
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GCM_INTERACTIVE: 'never' },
102
+ stdio: ['pipe', 'pipe', 'ignore'],
103
+ timeout: 5_000,
104
+ });
105
+ const field = (key: string) => out.match(new RegExp(`^${key}=(.*)$`, 'm'))?.[1];
106
+ const username = field('username');
107
+ const password = field('password');
108
+ return username && password ? { username, password } : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ },
79
113
  get,
80
114
  };
81
115
  }
@@ -160,7 +194,8 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
160
194
 
161
195
  /**
162
196
  * Whether the default branch on the hosting of `origin` takes changes only after a check
163
- * required of everyone. GitHub only; what vdx cannot ask is unknown, with the reason.
197
+ * required of everyone. GitHub, GitLab and Bitbucket Server; what vdx cannot ask is unknown,
198
+ * with the reason.
164
199
  */
165
200
  export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
166
201
  const key = 'hosting:branch-requires-checks';
@@ -172,12 +207,231 @@ export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.pro
172
207
 
173
208
  function ask(io: HostingIo): Verdict {
174
209
  const gh = githubRepo(io, 'origin');
175
- if (isUnknownRepo(gh)) return gh;
176
- try {
177
- return judge(io, gh.repo, gh.token);
178
- } catch (e) {
179
- return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
210
+ if (!isUnknownRepo(gh)) {
211
+ try {
212
+ return judge(io, gh.repo, gh.token);
213
+ } catch (e) {
214
+ return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
215
+ }
216
+ }
217
+ if (io.env.VDX_OFFLINE) return gh;
218
+ const url = io.git(['remote', 'get-url', 'origin']);
219
+ if (!url) return gh;
220
+ const { host, path: repo } = remoteParts(url);
221
+ // GitLab: the host GITLAB_HOST names, one glab has a token for (gitlab.finam.ru, DL #12),
222
+ // or one with gitlab in its name. A token goes only where it belongs: GITLAB_TOKEN to
223
+ // GITLAB_HOST, as glab sends it; glab's to its own host. Any other host is asked without
224
+ // one — an origin is the repository's to name, and may point anywhere.
225
+ const tokenHost = hostOf(io.env.GITLAB_HOST || 'gitlab.com');
226
+ const glab = io.glabToken(host);
227
+ const token = (host.toLowerCase() === tokenHost ? io.env.GITLAB_TOKEN : undefined) || glab;
228
+ if (host.toLowerCase() === tokenHost || glab || host.includes('gitlab')) {
229
+ try {
230
+ return judgeGitlab(io, host, repo, token);
231
+ } catch (e) {
232
+ return { unknown: `${host} did not answer: ${errorMessage(e)}` };
233
+ }
234
+ }
235
+ // Bitbucket Server: the host BITBUCKET_HOST names, one with bitbucket in its name, or one
236
+ // git keeps a login for (git.finam.ru, DL #16) — once the host, asked without a login,
237
+ // says it is Bitbucket. BITBUCKET_TOKEN goes to BITBUCKET_HOST only, git's login to the
238
+ // host git keeps it for.
239
+ const bitbucketHost = io.env.BITBUCKET_HOST ? hostOf(io.env.BITBUCKET_HOST) : null;
240
+ const login = io.gitCredential(host);
241
+ if (host.toLowerCase() === bitbucketHost || host.toLowerCase().includes('bitbucket') || login) {
242
+ const auth: BitbucketAuth | null =
243
+ host.toLowerCase() === bitbucketHost && io.env.BITBUCKET_TOKEN
244
+ ? { token: io.env.BITBUCKET_TOKEN, scheme: 'Bearer', source: 'BITBUCKET_TOKEN' }
245
+ : login
246
+ ? {
247
+ token: Buffer.from(`${login.username}:${login.password}`).toString('base64'),
248
+ scheme: 'Basic',
249
+ source: 'the login git keeps',
250
+ }
251
+ : null;
252
+ try {
253
+ const about = io.get(`https://${host}/rest/api/1.0/application-properties`, null);
254
+ if (about.status === 200 && at(about.body, 'displayName') === 'Bitbucket') {
255
+ return judgeBitbucket(io, host, repo, auth);
256
+ }
257
+ } catch (e) {
258
+ return { unknown: `${host} did not answer: ${errorMessage(e)}` };
259
+ }
260
+ }
261
+ // Not every host is asked whether it is Bitbucket: gitolite has no https, and the audit
262
+ // would wait out the timeout. The reason says how vdx tells Bitbucket.
263
+ return {
264
+ unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub, GitLab and Bitbucket Server, the last once git keeps a login for it or BITBUCKET_HOST names it`,
265
+ };
266
+ }
267
+
268
+ /** The host of a `*_HOST` variable, which may come as a URL. */
269
+ function hostOf(value: string): string {
270
+ return value
271
+ .replace(/^https?:\/\//, '')
272
+ .replace(/\/.*$/, '')
273
+ .toLowerCase();
274
+ }
275
+
276
+ /** A refusal of a GitLab API as the reason the answer is unknown; null for a 200. */
277
+ function gitlabRefusal(a: Answer, host: string, repo: string, token: string | null): Unknown | null {
278
+ if (a.status === 200) return null;
279
+ const message = str(at(a.body, 'message')) ?? str(at(a.body, 'error')) ?? '';
280
+ if (a.status === 429) return { unknown: `${host}'s rate limit is spent` };
281
+ if ((a.status === 401 || a.status === 404) && !token) {
282
+ return {
283
+ unknown: `${host} does not show ${repo} without a token — private? glab auth login --hostname ${host}, or GITLAB_TOKEN`,
284
+ };
285
+ }
286
+ return { unknown: `${host} answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
287
+ }
288
+
289
+ /**
290
+ * GitLab: the default branch takes changes only after a pipeline when nobody may push to
291
+ * it directly — a push skips the pipeline — and a merge request needs one that passed
292
+ * (`only_allow_merge_if_pipeline_succeeds`). The agent pushes as the owner (DL #3).
293
+ */
294
+ function judgeGitlab(io: HostingIo, host: string, repo: string, token: string | null): Verdict {
295
+ const base = `https://${host}/api/v4/projects/${encodeURIComponent(repo)}`;
296
+ const p = io.get(base, token);
297
+ const refused = gitlabRefusal(p, host, repo, token);
298
+ if (refused) return refused;
299
+ const branch =
300
+ io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '') ||
301
+ str(at(p.body, 'default_branch'));
302
+ if (!branch) return { unknown: `${host} names no default branch for ${repo}` };
303
+ const pipeline = at(p.body, 'only_allow_merge_if_pipeline_succeeds') === true;
304
+
305
+ const pb = io.get(`${base}/protected_branches/${encodeURIComponent(branch)}`, token);
306
+ const isProtected = pb.status === 200;
307
+ if (!isProtected && pb.status !== 404) {
308
+ const refusedBranch = gitlabRefusal(pb, host, repo, token);
309
+ if (refusedBranch) return refusedBranch;
310
+ }
311
+ // Who may push straight to the branch: a role above "No one" (0), a user, a group, a deploy key.
312
+ const pushers = list(at(pb.body, 'push_access_levels'))
313
+ .filter(
314
+ (l) =>
315
+ at(l, 'access_level') !== 0 ||
316
+ at(l, 'user_id') != null ||
317
+ at(l, 'group_id') != null ||
318
+ at(l, 'deploy_key_id') != null,
319
+ )
320
+ .map((l) => str(at(l, 'access_level_description')) ?? 'someone');
321
+ if (isProtected && pushers.length === 0 && pipeline) return true;
322
+
323
+ const why: string[] = [];
324
+ if (!isProtected) why.push(`${branch} is not protected`);
325
+ else if (pushers.length > 0)
326
+ why.push(`${branch} takes direct pushes from ${pushers.join(', ')} — a push skips the pipeline`);
327
+ if (!pipeline) why.push(`merge requests into ${branch} do not need a passing pipeline`);
328
+ return { no: why.join('; ') };
329
+ }
330
+
331
+ /** What vdx asks Bitbucket with, and where it comes from — for the reason when it is refused. */
332
+ interface BitbucketAuth {
333
+ token: string;
334
+ scheme: 'Bearer' | 'Basic';
335
+ source: string;
336
+ }
337
+
338
+ const MIN_BUILDS = 'com.atlassian.bitbucket.server.bitbucket-build:requiredBuildsMergeCheck';
339
+
340
+ /** A refusal of Bitbucket as the reason the answer is unknown; null for a 200. */
341
+ function bitbucketRefusal(a: Answer, host: string, repo: string, auth: BitbucketAuth | null): Unknown | null {
342
+ if (a.status === 200) return null;
343
+ const message = str(at(list(at(a.body, 'errors'))[0], 'message')) ?? '';
344
+ if (a.status === 401 && !auth) {
345
+ return {
346
+ unknown: `${host} shows ${repo} only after a login — once: git ls-remote https://${host}/scm/${repo}.git, a token as the password; or BITBUCKET_TOKEN with BITBUCKET_HOST`,
347
+ };
348
+ }
349
+ if (a.status === 401) {
350
+ return { unknown: `${host} refused ${repo} to ${auth?.source} — no access, a changed password, a revoked token?` };
351
+ }
352
+ return { unknown: `${host} answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
353
+ }
354
+
355
+ /**
356
+ * Bitbucket Server: the default branch takes changes only after a build when nothing
357
+ * reaches it but a pull request — a `pull-request-only` or `read-only` restriction on it
358
+ * exempts nobody; an exempt user pushes past the build — and a pull request needs a
359
+ * successful build (the merge check «Minimum successful builds»). Shapes of git.finam.ru,
360
+ * 5.16.1 (DL #17). The first refusal ends the questions: every login refused brings the
361
+ * owner's account nearer a CAPTCHA.
362
+ */
363
+ function judgeBitbucket(io: HostingIo, host: string, path: string, auth: BitbucketAuth | null): Verdict {
364
+ const [project, slug, ...rest] = path.replace(/^scm\//, '').split('/');
365
+ if (!project || !slug || rest.length > 0) return { unknown: `${host}: no project and repository in ${path}` };
366
+ const repo = `${project}/${slug}`;
367
+ const repoPath = `projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(slug)}`;
368
+ const api = `https://${host}/rest/api/1.0/${repoPath}`;
369
+ const ask = (url: string) => (auth ? io.get(url, auth.token, auth.scheme) : io.get(url, null));
370
+
371
+ let branch = io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '');
372
+ if (!branch) {
373
+ const d = ask(`${api}/branches/default`);
374
+ const refused = bitbucketRefusal(d, host, repo, auth);
375
+ if (refused) return refused;
376
+ branch = str(at(d.body, 'displayId'));
377
+ if (!branch) return { unknown: `${host} names no default branch for ${repo}` };
378
+ }
379
+
380
+ // Whoever reads the repository sees its merge checks; only an admin sees its restrictions.
381
+ const hooks = ask(`${api}/settings/hooks?type=PRE_PULL_REQUEST_MERGE&limit=100`);
382
+ const refusedHooks = bitbucketRefusal(hooks, host, repo, auth);
383
+ if (refusedHooks) return refusedHooks;
384
+ const builds = list(at(hooks.body, 'values')).some(
385
+ (h) => at(h, 'details', 'key') === MIN_BUILDS && at(h, 'enabled') === true,
386
+ );
387
+ const noBuilds = `pull requests into ${branch} need no successful build`;
388
+
389
+ const r = ask(`https://${host}/rest/branch-permissions/2.0/${repoPath}/restrictions?limit=100`);
390
+ if (r.status !== 200) {
391
+ // Without the build check the branch takes unbuilt changes, whoever may push to it.
392
+ if (!builds) return { no: noBuilds };
393
+ if (r.status === 401 && auth) {
394
+ return { unknown: `${host} shows who may change ${branch} of ${repo} only to a repository admin` };
395
+ }
396
+ return bitbucketRefusal(r, host, repo, auth) ?? { unknown: `${host} answered ${r.status} for ${repo}` };
397
+ }
398
+ if (at(r.body, 'isLastPage') === false) return { unknown: `${repo} has more than 100 branch restrictions` };
399
+
400
+ let guarded = false;
401
+ let byPattern = false;
402
+ const exempt = new Set<string>();
403
+ for (const x of list(at(r.body, 'values'))) {
404
+ if (!['read-only', 'pull-request-only'].includes(str(at(x, 'type')) ?? '')) continue;
405
+ if (at(x, 'matcher', 'type', 'id') !== 'BRANCH') {
406
+ byPattern = true;
407
+ continue;
408
+ }
409
+ if (at(x, 'matcher', 'id') !== `refs/heads/${branch}`) continue;
410
+ const who = [
411
+ ...list(at(x, 'users')).map((u) => str(at(u, 'name')) ?? 'a user'),
412
+ ...list(at(x, 'groups')).map((g) => `group ${str(g) ?? '?'}`),
413
+ ...list(at(x, 'accessKeys')).map((k) => `access key ${str(at(k, 'key', 'label')) ?? '?'}`),
414
+ ];
415
+ if (who.length === 0) guarded = true;
416
+ for (const w of who) exempt.add(w);
417
+ }
418
+
419
+ // A pattern or the branching model may cover the branch: vdx does not read them (DL #17).
420
+ if (!guarded && byPattern && builds) {
421
+ return {
422
+ unknown: `a branch restriction by pattern or branching model on ${repo} may cover ${branch} — vdx reads those that name the branch`,
423
+ };
424
+ }
425
+ const why: string[] = [];
426
+ if (!guarded && !byPattern) {
427
+ why.push(
428
+ exempt.size > 0
429
+ ? `${branch} takes changes without a pull request from ${[...exempt].join(', ')} — they skip the build`
430
+ : `${branch} is not restricted — whoever may write pushes to it`,
431
+ );
180
432
  }
433
+ if (!builds) why.push(noBuilds);
434
+ return why.length > 0 ? { no: why.join('; ') } : true;
181
435
  }
182
436
 
183
437
  /** 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,14 @@ 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; Bitbucket Server, with the login git keeps for that host
135
+ (git credential), or $BITBUCKET_TOKEN when the host is $BITBUCKET_HOST.
136
+ Another hosting is unknown; VDX_OFFLINE=1 does not ask, and the axis says
137
+ unknown.
133
138
  `,
134
139
  bools: ['json'],
135
140
  values: ['rubric', 'stack', 'format'],