@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.1
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/CHANGELOG.md +42 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +10 -2
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +41 -6
- package/install/codex.mjs +3 -3
- package/manifest.json +62 -49
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/commands/sim-test.md +71 -6
- package/pipeline/lib/plugin-skill-roots.mjs +80 -0
- package/pipeline/lib/usage-lifecycle.mjs +83 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
- package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
- package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +129 -36
- package/pipeline/multi-agent-refs/features/valven.md +30 -3
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
- package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
- package/pipeline/schemas/explore-run.schema.json +108 -0
- package/pipeline/schemas/prefs.schema.json +24 -3
- package/pipeline/scripts/_stack-routing.mjs +2 -0
- package/pipeline/scripts/bug-bash.mjs +379 -0
- package/pipeline/scripts/explore-findings.mjs +410 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -2
- package/pipeline/scripts/pr-timeline.mjs +135 -0
- package/pipeline/scripts/pre-commit-check.sh +4 -2
- package/pipeline/scripts/skill-conformance.mjs +17 -71
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/uninstall.mjs +29 -8
- package/pipeline/scripts/usage-identity.mjs +311 -26
- package/pipeline/scripts/usage-register.mjs +268 -62
- package/pipeline/scripts/usage-report.mjs +59 -7
- package/pipeline/scripts/valven-gate.mjs +292 -11
- package/pipeline/skills/.skill-manifest.json +18 -10
- package/pipeline/skills/.skills-index.json +28 -6
- package/pipeline/skills/shared/README.md +6 -4
- package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
- package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
- package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
- package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
- package/pipeline/skills/skills-index.md +4 -2
- package/pipeline/skills/shared/core/valven-rules/SKILL.md +0 -62
|
@@ -81,11 +81,15 @@ export const PIPELINE_AGENT_FILES = [
|
|
|
81
81
|
* Non-multi-agent core skills the installer ships from
|
|
82
82
|
* pipeline/skills/shared/core/ (the multi-agent* dirs are matched by prefix).
|
|
83
83
|
*/
|
|
84
|
-
export const PIPELINE_CORE_SKILL_DIRS = [
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
export const PIPELINE_CORE_SKILL_DIRS = ["apple-archive-compliance", "google-play-compliance"];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Skills an older install placed in the skills dir that now reach the host from
|
|
88
|
+
* a marketplace plugin (Claude Code) or the external catalog (Copilot, Codex).
|
|
89
|
+
* An install upgraded since then has already pruned them; one that was not
|
|
90
|
+
* still carries the old copy, so uninstall removes these names too.
|
|
91
|
+
*/
|
|
92
|
+
export const PIPELINE_RETIRED_SKILL_DIRS = ["valven-rules"];
|
|
89
93
|
|
|
90
94
|
/**
|
|
91
95
|
* Files the installer ships into ~/.claude/templates (install/templates/).
|
|
@@ -323,7 +327,7 @@ function rmManifestFiles(hostDir, keepInLib) {
|
|
|
323
327
|
* @param {string} path
|
|
324
328
|
*/
|
|
325
329
|
function rmIfExists(path) {
|
|
326
|
-
if (!existsSync(path)) return false;
|
|
330
|
+
if (!existsSync(path) && !isSymlink(path)) return false;
|
|
327
331
|
if (dryRun) {
|
|
328
332
|
report("would remove", path);
|
|
329
333
|
return false;
|
|
@@ -538,6 +542,15 @@ function rmExternalDeliveredSkills(skillsDir) {
|
|
|
538
542
|
return n;
|
|
539
543
|
}
|
|
540
544
|
|
|
545
|
+
/** True for a symlink, dangling or not; existsSync follows the link and misses a dangling one. */
|
|
546
|
+
function isSymlink(path) {
|
|
547
|
+
try {
|
|
548
|
+
return lstatSync(path).isSymbolicLink();
|
|
549
|
+
} catch {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
541
554
|
function rmMatchingDirs(parent, predicate) {
|
|
542
555
|
if (!existsSync(parent)) return 0;
|
|
543
556
|
let count = 0;
|
|
@@ -879,7 +892,11 @@ export async function main() {
|
|
|
879
892
|
name === "figma-common" ||
|
|
880
893
|
name === "figma-to-component",
|
|
881
894
|
);
|
|
882
|
-
n += rmMatchingDirs(
|
|
895
|
+
n += rmMatchingDirs(
|
|
896
|
+
skills,
|
|
897
|
+
(name) =>
|
|
898
|
+
PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
|
|
899
|
+
);
|
|
883
900
|
n += rmExternalDeliveredSkills(skills);
|
|
884
901
|
if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
|
|
885
902
|
rmIfExists(join(skills, ".skills-index.json"));
|
|
@@ -914,7 +931,11 @@ export async function main() {
|
|
|
914
931
|
name === "figma-common" ||
|
|
915
932
|
name === "figma-to-component",
|
|
916
933
|
);
|
|
917
|
-
n += rmMatchingDirs(
|
|
934
|
+
n += rmMatchingDirs(
|
|
935
|
+
skills,
|
|
936
|
+
(name) =>
|
|
937
|
+
PIPELINE_CORE_SKILL_DIRS.includes(name) || PIPELINE_RETIRED_SKILL_DIRS.includes(name),
|
|
938
|
+
);
|
|
918
939
|
n += rmPluginDeliveredSkills(skills);
|
|
919
940
|
n += rmExternalDeliveredSkills(skills);
|
|
920
941
|
if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
|
|
@@ -2,23 +2,44 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* usage-identity.mjs - the GitHub account this machine reports usage under.
|
|
4
4
|
*
|
|
5
|
-
* The reporting identity is the
|
|
6
|
-
* pipeline repository (`usageLog.accessRepo`, default
|
|
5
|
+
* The reporting identity is the GitHub account on this machine that can read
|
|
6
|
+
* the pipeline repository (`usageLog.accessRepo`, default
|
|
7
7
|
* mmerterden/multi-agent-pipeline). Access to that repository is what grants
|
|
8
8
|
* use of the pipeline, so it is also what decides whose name a run carries.
|
|
9
9
|
* The account `gh` happens to have active is not used as such: a machine with
|
|
10
|
-
* a work account and a personal account
|
|
11
|
-
*
|
|
10
|
+
* a work account and a personal account reports under whichever of the two
|
|
11
|
+
* actually holds access.
|
|
12
12
|
*
|
|
13
|
-
* Resolution
|
|
14
|
-
* in order `usageLog.reportAs` (when it is one of them), the active account,
|
|
15
|
-
* then the rest. Each check runs `gh api repos/<accessRepo>` with that
|
|
16
|
-
* account's token in the child's environment (GH_TOKEN), never on argv and
|
|
17
|
-
* never written anywhere. The first account that can read the repository wins.
|
|
13
|
+
* Resolution stops at the first account that can read the repository:
|
|
18
14
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* 1. gh: every github.com account `gh auth status` lists, in order
|
|
16
|
+
* `usageLog.reportAs` (when it is one of them), the active account, then
|
|
17
|
+
* the rest. Each check runs `gh api repos/<accessRepo>` with that
|
|
18
|
+
* account's token in the child's environment (GH_TOKEN).
|
|
19
|
+
* 2. credential: the token git's credential helper holds for
|
|
20
|
+
* https://github.com (`git credential fill`, prompts disabled). Its login
|
|
21
|
+
* comes from `GET /user`, its access from `GET /repos/<accessRepo>`, both
|
|
22
|
+
* through gh with GH_TOKEN, or straight to the API when gh is missing.
|
|
23
|
+
* 3. ssh: each private key `ssh -G github.com` names, plus ~/.ssh/id_ed25519,
|
|
24
|
+
* id_ecdsa and id_rsa, when its `.pub` sits next to it. `ssh -T` with only
|
|
25
|
+
* that key names the login ("Hi <login>!"), and
|
|
26
|
+
* `git ls-remote git@github.com:<accessRepo>.git HEAD` with the same key
|
|
27
|
+
* proves access. Batch mode throughout, so a key that needs a passphrase
|
|
28
|
+
* and is not in ssh-agent is skipped rather than prompted for.
|
|
29
|
+
*
|
|
30
|
+
* No token is ever put on an argv, printed or written anywhere. A missing gh,
|
|
31
|
+
* git or ssh binary, or any failed check, only means "no candidate".
|
|
32
|
+
*
|
|
33
|
+
* When no account qualifies the login is null and `reason` says what was tried
|
|
34
|
+
* in one line; nothing is reported under another account.
|
|
35
|
+
*
|
|
36
|
+
* The result is cached in prefs as `usageLog.login`, `usageLog.loginMethod`
|
|
37
|
+
* (gh | credential | ssh), `usageLog.loginKey` (the key path, ssh only),
|
|
38
|
+
* `usageLog.loginRepo`, `usageLog.loginCheckedAt` and `usageLog.loginReason`
|
|
39
|
+
* (why none qualified), re-checked after LOGIN_TTL_MS, after NO_LOGIN_TTL_MS
|
|
40
|
+
* when no account had access, when the cached key file is gone, or on
|
|
41
|
+
* `--refresh`. A cache entry marked `usageLog.loginFallback` (written by
|
|
42
|
+
* 20.17.0) is never served.
|
|
22
43
|
*
|
|
23
44
|
* Usage:
|
|
24
45
|
* node usage-identity.mjs [--refresh] [--prefs <path>] [--json]
|
|
@@ -30,7 +51,7 @@
|
|
|
30
51
|
|
|
31
52
|
import { existsSync, readFileSync } from "node:fs";
|
|
32
53
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
33
|
-
import { homedir } from "node:os";
|
|
54
|
+
import { homedir, userInfo } from "node:os";
|
|
34
55
|
import { join } from "node:path";
|
|
35
56
|
import { pathToFileURL } from "node:url";
|
|
36
57
|
import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
|
|
@@ -39,7 +60,13 @@ export const ACCESS_REPO_DEFAULT = "mmerterden/multi-agent-pipeline";
|
|
|
39
60
|
export const LOGIN_TTL_MS = 7 * 24 * 3_600_000;
|
|
40
61
|
export const NO_LOGIN_TTL_MS = 24 * 3_600_000;
|
|
41
62
|
const GH_TIMEOUT_MS = 6000;
|
|
63
|
+
export const SSH_TIMEOUT_MS = 12000;
|
|
64
|
+
const SSH_CONNECT_TIMEOUT_S = 8;
|
|
65
|
+
const MAX_SSH_KEYS = 8;
|
|
42
66
|
const HOST = "github.com";
|
|
67
|
+
const API_DEFAULT = "https://api.github.com";
|
|
68
|
+
const DEFAULT_KEYS = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
69
|
+
export const LOGIN_METHODS = ["gh", "credential", "ssh"];
|
|
43
70
|
const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
|
|
44
71
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
45
72
|
|
|
@@ -59,6 +86,28 @@ function ghBin(env) {
|
|
|
59
86
|
return env.MA_GH_BIN || "gh";
|
|
60
87
|
}
|
|
61
88
|
|
|
89
|
+
function gitBin(env) {
|
|
90
|
+
return env.MA_GIT_BIN || "git";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sshBin(env) {
|
|
94
|
+
return env.MA_SSH_BIN || "ssh";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function sshKeygenBin(env = process.env) {
|
|
98
|
+
return env.MA_SSH_KEYGEN_BIN || "ssh-keygen";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function homeOf(env) {
|
|
102
|
+
return env.HOME || homedir();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `~/...` for a path under the home directory, for status lines. */
|
|
106
|
+
export function displayPath(path, env = process.env) {
|
|
107
|
+
const home = homeOf(env);
|
|
108
|
+
return path && home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
109
|
+
}
|
|
110
|
+
|
|
62
111
|
export function accessRepoOf(prefs) {
|
|
63
112
|
const r = prefs?.global?.usageLog?.accessRepo;
|
|
64
113
|
return typeof r === "string" && REPO_RE.test(r.trim()) ? r.trim() : ACCESS_REPO_DEFAULT;
|
|
@@ -157,8 +206,182 @@ function orderCandidates(accounts, preferred) {
|
|
|
157
206
|
}
|
|
158
207
|
|
|
159
208
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
209
|
+
* The token git's credential helper holds for https://github.com, or "".
|
|
210
|
+
* Prompts are disabled (terminal, askpass and helper UI), so a
|
|
211
|
+
* machine without a stored credential answers at once. The token is read from
|
|
212
|
+
* the helper's stdout and only ever passed on in a child's environment.
|
|
213
|
+
*/
|
|
214
|
+
export function credentialToken(env = process.env) {
|
|
215
|
+
const r = spawnSync(gitBin(env), ["credential", "fill"], {
|
|
216
|
+
input: `protocol=https\nhost=${HOST}\n\n`,
|
|
217
|
+
encoding: "utf8",
|
|
218
|
+
timeout: GH_TIMEOUT_MS,
|
|
219
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
220
|
+
env: {
|
|
221
|
+
...env,
|
|
222
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
223
|
+
GCM_INTERACTIVE: "never",
|
|
224
|
+
GIT_ASKPASS: "",
|
|
225
|
+
SSH_ASKPASS: "",
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return "";
|
|
229
|
+
const line = r.stdout.split("\n").find((l) => l.startsWith("password="));
|
|
230
|
+
return line ? line.slice("password=".length).trim() : "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function apiBase(env) {
|
|
234
|
+
const v = String(env.MA_GITHUB_API_URL || "");
|
|
235
|
+
return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(v) ? v : API_DEFAULT;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const API_CHECK = `
|
|
239
|
+
const base = process.env.MA_CHECK_API;
|
|
240
|
+
const repo = process.env.MA_CHECK_REPO;
|
|
241
|
+
const headers = {
|
|
242
|
+
Authorization: "Bearer " + process.env.MA_CHECK_TOKEN,
|
|
243
|
+
Accept: "application/vnd.github+json",
|
|
244
|
+
"User-Agent": "multi-agent-pipeline",
|
|
245
|
+
};
|
|
246
|
+
const get = (path) =>
|
|
247
|
+
fetch(base + path, { headers, redirect: "error", signal: AbortSignal.timeout(${GH_TIMEOUT_MS}) });
|
|
248
|
+
(async () => {
|
|
249
|
+
const u = await get("/user");
|
|
250
|
+
if (!u.ok) process.exit(1);
|
|
251
|
+
const login = (await u.json())?.login;
|
|
252
|
+
const r = await get("/repos/" + repo);
|
|
253
|
+
if (!r.ok) process.exit(1);
|
|
254
|
+
process.stdout.write(String(login || ""));
|
|
255
|
+
})().catch(() => process.exit(1));
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The login a token belongs to, when that login can read `repo`; else null.
|
|
260
|
+
* Through gh when it is installed, otherwise a direct API call from a child
|
|
261
|
+
* node process. Either way the token travels in the child's environment.
|
|
262
|
+
*/
|
|
263
|
+
function tokenLoginWithAccess(token, repo, env) {
|
|
264
|
+
const who = spawnSync(ghBin(env), ["api", "user", "--jq", ".login"], {
|
|
265
|
+
encoding: "utf8",
|
|
266
|
+
timeout: GH_TIMEOUT_MS,
|
|
267
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
268
|
+
env: { ...env, GH_TOKEN: token, GH_HOST: HOST, GITHUB_TOKEN: "" },
|
|
269
|
+
});
|
|
270
|
+
if (who.error?.code === "ENOENT") {
|
|
271
|
+
const r = spawnSync(process.execPath, ["-e", API_CHECK], {
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
timeout: GH_TIMEOUT_MS * 2,
|
|
274
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
275
|
+
env: { ...env, MA_CHECK_TOKEN: token, MA_CHECK_REPO: repo, MA_CHECK_API: apiBase(env) },
|
|
276
|
+
});
|
|
277
|
+
const login = (r.stdout || "").trim();
|
|
278
|
+
return r.status === 0 && LOGIN_RE.test(login) ? login : null;
|
|
279
|
+
}
|
|
280
|
+
const login = (who.stdout || "").trim();
|
|
281
|
+
if (who.status !== 0 || !LOGIN_RE.test(login)) return null;
|
|
282
|
+
return canRead(repo, token, env) ? login : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function shQuote(s) {
|
|
286
|
+
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const SSH_FLAGS = [
|
|
290
|
+
"-o",
|
|
291
|
+
"IdentitiesOnly=yes",
|
|
292
|
+
"-o",
|
|
293
|
+
"BatchMode=yes",
|
|
294
|
+
"-o",
|
|
295
|
+
`ConnectTimeout=${SSH_CONNECT_TIMEOUT_S}`,
|
|
296
|
+
"-o",
|
|
297
|
+
"StrictHostKeyChecking=accept-new",
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
function sshEnv(env) {
|
|
301
|
+
return { ...env, SSH_ASKPASS_REQUIRE: "never", GIT_TERMINAL_PROMPT: "0" };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function expandIdentity(raw, env) {
|
|
305
|
+
const home = homeOf(env);
|
|
306
|
+
let user = env.USER || "";
|
|
307
|
+
if (!user) {
|
|
308
|
+
try {
|
|
309
|
+
user = userInfo().username;
|
|
310
|
+
} catch {
|
|
311
|
+
user = "";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const parts = raw
|
|
315
|
+
.trim()
|
|
316
|
+
.replace(/^"(.*)"$/, "$1")
|
|
317
|
+
.replace(/^~(?=\/|$)/, home)
|
|
318
|
+
.split("%%")
|
|
319
|
+
.map((part) => part.replace(/%d/g, home).replace(/%u/g, user).replace(/%h/g, HOST));
|
|
320
|
+
return parts.some((part) => part.includes("%")) ? null : parts.join("%");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function usableKey(path) {
|
|
324
|
+
if (!path) return false;
|
|
325
|
+
if (path.endsWith(".pub")) return existsSync(path);
|
|
326
|
+
return existsSync(path) && existsSync(`${path}.pub`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Private-key paths worth trying for github.com: what `ssh -G github.com`
|
|
331
|
+
* names, then the default key names under ~/.ssh, each kept only when its
|
|
332
|
+
* `.pub` exists (a `.pub` named directly stands for a key held by an agent).
|
|
333
|
+
* Empty when ssh is not installed.
|
|
334
|
+
*/
|
|
335
|
+
export function sshKeyCandidates(env = process.env) {
|
|
336
|
+
const r = spawnSync(sshBin(env), ["-G", HOST], {
|
|
337
|
+
encoding: "utf8",
|
|
338
|
+
timeout: GH_TIMEOUT_MS,
|
|
339
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
340
|
+
env,
|
|
341
|
+
});
|
|
342
|
+
if (r.error) return [];
|
|
343
|
+
const listed = [];
|
|
344
|
+
for (const line of (r.stdout || "").split("\n")) {
|
|
345
|
+
const m = line.match(/^identityfile\s+(.+)$/i);
|
|
346
|
+
if (m) listed.push(expandIdentity(m[1], env));
|
|
347
|
+
}
|
|
348
|
+
const defaults = DEFAULT_KEYS.map((n) => join(homeOf(env), ".ssh", n));
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const p of [...listed, ...defaults]) {
|
|
351
|
+
if (p && !out.includes(p) && usableKey(p)) out.push(p);
|
|
352
|
+
}
|
|
353
|
+
return out.slice(0, MAX_SSH_KEYS);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The GitHub login an SSH key authenticates as, from `ssh -T`; else null. */
|
|
357
|
+
export function sshLogin(key, env = process.env) {
|
|
358
|
+
const r = spawnSync(sshBin(env), ["-i", key, ...SSH_FLAGS, "-T", `git@${HOST}`], {
|
|
359
|
+
encoding: "utf8",
|
|
360
|
+
timeout: SSH_TIMEOUT_MS,
|
|
361
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
362
|
+
env: sshEnv(env),
|
|
363
|
+
});
|
|
364
|
+
if (r.error) return null;
|
|
365
|
+
const m = `${r.stderr || ""}\n${r.stdout || ""}`.match(/Hi ([^\s!]+)!/);
|
|
366
|
+
return m && LOGIN_RE.test(m[1]) ? m[1] : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sshCanRead(key, repo, env) {
|
|
370
|
+
const command = [shQuote(sshBin(env)), "-i", shQuote(key), ...SSH_FLAGS].join(" ");
|
|
371
|
+
const r = spawnSync(gitBin(env), ["ls-remote", `git@${HOST}:${repo}.git`, "HEAD"], {
|
|
372
|
+
encoding: "utf8",
|
|
373
|
+
timeout: SSH_TIMEOUT_MS,
|
|
374
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
375
|
+
env: { ...sshEnv(env), GIT_SSH_COMMAND: command },
|
|
376
|
+
});
|
|
377
|
+
return r.status === 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Check the accounts on this machine against the access repository, live, in
|
|
382
|
+
* the order gh, git credential helper, SSH keys. Returns
|
|
383
|
+
* { login, method, key, reason }: the first account with access, else login
|
|
384
|
+
* null with a one-line reason naming what was tried.
|
|
162
385
|
*/
|
|
163
386
|
export function checkAccess({
|
|
164
387
|
repo = ACCESS_REPO_DEFAULT,
|
|
@@ -166,26 +389,70 @@ export function checkAccess({
|
|
|
166
389
|
env = process.env,
|
|
167
390
|
} = {}) {
|
|
168
391
|
const accounts = listAccounts({ env });
|
|
169
|
-
|
|
392
|
+
for (const a of orderCandidates(accounts, preferred)) {
|
|
393
|
+
const token = tokenFor(a.login, env);
|
|
394
|
+
if (token && canRead(repo, token, env)) {
|
|
395
|
+
return { login: a.login, method: "gh", key: null, reason: `can read ${repo}` };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const cred = credentialToken(env);
|
|
400
|
+
const credLogin = cred ? tokenLoginWithAccess(cred, repo, env) : null;
|
|
401
|
+
if (credLogin) {
|
|
170
402
|
return {
|
|
171
|
-
login:
|
|
172
|
-
|
|
403
|
+
login: credLogin,
|
|
404
|
+
method: "credential",
|
|
405
|
+
key: null,
|
|
406
|
+
reason: `can read ${repo}`,
|
|
173
407
|
};
|
|
174
408
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
409
|
+
|
|
410
|
+
const keys = sshKeyCandidates(env);
|
|
411
|
+
const sshLogins = [];
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
const login = sshLogin(key, env);
|
|
414
|
+
if (!login) continue;
|
|
415
|
+
sshLogins.push(login);
|
|
416
|
+
if (sshCanRead(key, repo, env)) {
|
|
417
|
+
return {
|
|
418
|
+
login,
|
|
419
|
+
method: "ssh",
|
|
420
|
+
key,
|
|
421
|
+
reason: `can read ${repo}`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
178
424
|
}
|
|
425
|
+
|
|
426
|
+
const ghPart = accounts.length ? accounts.map((a) => a.login).join(", ") : "none";
|
|
427
|
+
const credPart = cred ? "a token without access" : "none";
|
|
428
|
+
const sshPart = keys.length
|
|
429
|
+
? `${keys.length} tried${sshLogins.length ? ` (${[...new Set(sshLogins)].join(", ")})` : ""}`
|
|
430
|
+
: "none";
|
|
179
431
|
return {
|
|
180
432
|
login: null,
|
|
181
|
-
|
|
433
|
+
method: null,
|
|
434
|
+
key: null,
|
|
435
|
+
reason: `no GitHub account on this machine can read ${repo} (gh: ${ghPart}; git credential: ${credPart}; SSH keys: ${sshPart})`,
|
|
182
436
|
};
|
|
183
437
|
}
|
|
184
438
|
|
|
439
|
+
function cachedMethod(u) {
|
|
440
|
+
if (u.loginMethod === undefined || u.loginMethod === null) return "gh";
|
|
441
|
+
return LOGIN_METHODS.includes(u.loginMethod) ? u.loginMethod : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
185
444
|
function cacheFresh(u, repo, now) {
|
|
186
445
|
const at = Date.parse(u?.loginCheckedAt || "");
|
|
187
446
|
if (Number.isNaN(at)) return false;
|
|
188
447
|
if ((u.loginRepo || ACCESS_REPO_DEFAULT) !== repo) return false;
|
|
448
|
+
if (u.loginFallback === true) return false;
|
|
449
|
+
if (u.login) {
|
|
450
|
+
const method = cachedMethod(u);
|
|
451
|
+
if (!method) return false;
|
|
452
|
+
if (method === "ssh" && !(typeof u.loginKey === "string" && usableKey(u.loginKey))) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
189
456
|
const ttl = u.login ? LOGIN_TTL_MS : NO_LOGIN_TTL_MS;
|
|
190
457
|
return now - at < ttl && now >= at;
|
|
191
458
|
}
|
|
@@ -224,11 +491,20 @@ export function resolveReportingLogin({
|
|
|
224
491
|
const repo = accessRepoOf(prefs);
|
|
225
492
|
if (!refresh && cacheFresh(u, repo, now)) {
|
|
226
493
|
if (typeof u.login === "string" && LOGIN_RE.test(u.login)) {
|
|
227
|
-
|
|
494
|
+
const method = cachedMethod(u);
|
|
495
|
+
return {
|
|
496
|
+
login: u.login,
|
|
497
|
+
method,
|
|
498
|
+
key: method === "ssh" ? u.loginKey : null,
|
|
499
|
+
reason: `cached: can read ${repo}`,
|
|
500
|
+
cached: true,
|
|
501
|
+
};
|
|
228
502
|
}
|
|
229
503
|
return {
|
|
230
504
|
login: null,
|
|
231
|
-
|
|
505
|
+
method: null,
|
|
506
|
+
key: null,
|
|
507
|
+
reason: u.loginReason || `cached: no GitHub account on this machine can read ${repo}`,
|
|
232
508
|
cached: true,
|
|
233
509
|
};
|
|
234
510
|
}
|
|
@@ -239,6 +515,9 @@ export function resolveReportingLogin({
|
|
|
239
515
|
prefsPath,
|
|
240
516
|
{
|
|
241
517
|
login: result.login,
|
|
518
|
+
loginMethod: result.method,
|
|
519
|
+
loginKey: result.method === "ssh" ? result.key : null,
|
|
520
|
+
loginFallback: undefined,
|
|
242
521
|
loginRepo: repo,
|
|
243
522
|
loginCheckedAt: new Date(now).toISOString(),
|
|
244
523
|
loginReason: result.login ? null : result.reason,
|
|
@@ -263,10 +542,16 @@ function main(argv) {
|
|
|
263
542
|
});
|
|
264
543
|
if (opts.json) {
|
|
265
544
|
process.stdout.write(
|
|
266
|
-
`${JSON.stringify({ login: r.login, reason: r.reason, cached: r.cached })}\n`,
|
|
545
|
+
`${JSON.stringify({ login: r.login, method: r.method, key: r.key, reason: r.reason, cached: r.cached })}\n`,
|
|
267
546
|
);
|
|
268
547
|
} else if (r.login) {
|
|
269
|
-
|
|
548
|
+
const via =
|
|
549
|
+
r.method === "ssh"
|
|
550
|
+
? `SSH key ${displayPath(r.key)}`
|
|
551
|
+
: r.method === "credential"
|
|
552
|
+
? "git credential helper"
|
|
553
|
+
: "gh";
|
|
554
|
+
process.stdout.write(`usage identity: ${r.login} (${via}; ${r.reason})\n`);
|
|
270
555
|
} else {
|
|
271
556
|
process.stdout.write(`usage identity: none - ${r.reason}; nothing will be reported\n`);
|
|
272
557
|
}
|