@vodmal/vdx-cli 0.33.0 → 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 +13 -3
- package/package.json +1 -1
- package/src/hosting.ts +185 -13
- package/src/index.ts +3 -1
package/README.md
CHANGED
|
@@ -134,7 +134,16 @@ nobody bypasses; a public repo needs no token, a private one takes it from
|
|
|
134
134
|
its name, or one glab has a token for): nobody may push to the branch directly,
|
|
135
135
|
and a merge request needs a passing pipeline; the token is glab's for that
|
|
136
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.
|
|
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
|
|
138
147
|
where one is needed leave the axis unknown, with the reason too;
|
|
139
148
|
`VDX_OFFLINE=1` does not ask at all.
|
|
140
149
|
|
|
@@ -177,8 +186,9 @@ npm run typecheck
|
|
|
177
186
|
## Not implemented yet
|
|
178
187
|
|
|
179
188
|
- Above L1 the `ci` axis reads GitHub Actions only; other CI systems answer
|
|
180
|
-
"unknown". `branch-protection` reads GitHub and
|
|
181
|
-
(gitolite, Bitbucket) answer "unknown"
|
|
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.
|
|
182
192
|
- Baseline loading from a git ref is documented but evaluator still reads file
|
|
183
193
|
paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
|
|
184
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.
|
|
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
|
@@ -19,16 +19,26 @@ export interface HostingIo {
|
|
|
19
19
|
ghToken: () => string | null;
|
|
20
20
|
/** The token `glab` keeps for a GitLab host; null without glab or a login there. */
|
|
21
21
|
glabToken: (host: string) => string | null;
|
|
22
|
-
/**
|
|
23
|
-
|
|
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;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
// A child node runs the request: the audit is synchronous and fetch is not
|
|
27
32
|
// (DL #6 of docs/tasks/hosting-extension). The token travels in its
|
|
28
|
-
// 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).
|
|
29
35
|
const GET = `
|
|
30
|
-
const headers =
|
|
31
|
-
|
|
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
|
+
}
|
|
32
42
|
try {
|
|
33
43
|
const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(15000) });
|
|
34
44
|
process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
|
|
@@ -37,10 +47,11 @@ try {
|
|
|
37
47
|
}
|
|
38
48
|
`;
|
|
39
49
|
|
|
40
|
-
function get(apiPath: string, token: string | null): Answer {
|
|
50
|
+
function get(apiPath: string, token: string | null, scheme: 'Bearer' | 'Basic' = 'Bearer'): Answer {
|
|
41
51
|
const env = { ...process.env };
|
|
42
52
|
delete env.VDX_HOSTING_TOKEN;
|
|
43
|
-
|
|
53
|
+
delete env.VDX_HOSTING_SCHEME;
|
|
54
|
+
if (token) Object.assign(env, { VDX_HOSTING_TOKEN: token, VDX_HOSTING_SCHEME: scheme });
|
|
44
55
|
const url = apiPath.startsWith('https://') ? apiPath : `https://api.github.com${apiPath}`;
|
|
45
56
|
const out: unknown = JSON.parse(
|
|
46
57
|
execFileSync(process.execPath, ['--input-type=module', '-e', GET, url], {
|
|
@@ -80,6 +91,25 @@ export function machineIo(root: string): HostingIo {
|
|
|
80
91
|
git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
|
|
81
92
|
ghToken: () => run('gh', ['auth', 'token']),
|
|
82
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
|
+
},
|
|
83
113
|
get,
|
|
84
114
|
};
|
|
85
115
|
}
|
|
@@ -164,7 +194,8 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
|
164
194
|
|
|
165
195
|
/**
|
|
166
196
|
* Whether the default branch on the hosting of `origin` takes changes only after a check
|
|
167
|
-
* required of everyone. GitHub and
|
|
197
|
+
* required of everyone. GitHub, GitLab and Bitbucket Server; what vdx cannot ask is unknown,
|
|
198
|
+
* with the reason.
|
|
168
199
|
*/
|
|
169
200
|
export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
|
|
170
201
|
const key = 'hosting:branch-requires-checks';
|
|
@@ -191,10 +222,7 @@ function ask(io: HostingIo): Verdict {
|
|
|
191
222
|
// or one with gitlab in its name. A token goes only where it belongs: GITLAB_TOKEN to
|
|
192
223
|
// GITLAB_HOST, as glab sends it; glab's to its own host. Any other host is asked without
|
|
193
224
|
// 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();
|
|
225
|
+
const tokenHost = hostOf(io.env.GITLAB_HOST || 'gitlab.com');
|
|
198
226
|
const glab = io.glabToken(host);
|
|
199
227
|
const token = (host.toLowerCase() === tokenHost ? io.env.GITLAB_TOKEN : undefined) || glab;
|
|
200
228
|
if (host.toLowerCase() === tokenHost || glab || host.includes('gitlab')) {
|
|
@@ -204,7 +232,45 @@ function ask(io: HostingIo): Verdict {
|
|
|
204
232
|
return { unknown: `${host} did not answer: ${errorMessage(e)}` };
|
|
205
233
|
}
|
|
206
234
|
}
|
|
207
|
-
|
|
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();
|
|
208
274
|
}
|
|
209
275
|
|
|
210
276
|
/** A refusal of a GitLab API as the reason the answer is unknown; null for a 200. */
|
|
@@ -262,6 +328,112 @@ function judgeGitlab(io: HostingIo, host: string, repo: string, token: string |
|
|
|
262
328
|
return { no: why.join('; ') };
|
|
263
329
|
}
|
|
264
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
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (!builds) why.push(noBuilds);
|
|
434
|
+
return why.length > 0 ? { no: why.join('; ') } : true;
|
|
435
|
+
}
|
|
436
|
+
|
|
265
437
|
/** A remote's repository on GitHub and the token to ask with; unknown, with the reason, when vdx cannot ask. */
|
|
266
438
|
export function githubRepo(io: HostingIo, remote: string): { repo: string; token: string | null } | Unknown {
|
|
267
439
|
if (io.env.VDX_OFFLINE) return { unknown: 'VDX_OFFLINE is set — the hosting was not asked' };
|
package/src/index.ts
CHANGED
|
@@ -131,7 +131,9 @@ For branch-protection it asks the hosting of origin whether its default branch
|
|
|
131
131
|
takes changes only after a check required of everyone: GitHub, with a token from
|
|
132
132
|
$GH_TOKEN, $GITHUB_TOKEN or gh when there is one; GitLab, with glab's token for that host,
|
|
133
133
|
or $GITLAB_TOKEN when the host is $GITLAB_HOST (gitlab.com unless set) — no
|
|
134
|
-
other host gets it
|
|
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
|
|
135
137
|
unknown.
|
|
136
138
|
`,
|
|
137
139
|
bools: ['json'],
|