yadflow 4.4.0 → 4.5.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.
@@ -0,0 +1,822 @@
1
+ // `yad member add` / `yad member list` / `yad member remove` (E131) — who is on the team, and every
2
+ // identity each person shows up under.
3
+ //
4
+ // E62 removed the roster because it did six jobs. This record does ONE: it says that a git email and a
5
+ // set of platform accounts are one person. It holds no role, requests no reviewer, and is no allowlist —
6
+ // write access to the repo is still the only allowlist (E62 (3)). The decisions were the user's
7
+ // (2026-10-05):
8
+ // 1. `yad join` runs `yad member add`: it logs the person in on the Product's platform when they are
9
+ // not (`gh auth login` / `glab auth login`, interactive only), and records only what it can prove.
10
+ // 2. ONE FILE PER PERSON, `.sdlc/members/<platform>-<login>.json`, named by their account on the
11
+ // Product's own platform and host. It also lists their other accounts (GitHub, GitLab, a
12
+ // self-hosted host), so an account belongs to one member only — two files naming one account, or
13
+ // one email, are refused by the CI check and reported by `yad doctor`.
14
+ // 3. IDENTITY ONLY FOR THE GATE COUNT. A member file can only JOIN two keys of one person (a commit's
15
+ // email to an approval's login). It never removes anyone from the count: a person who never joined
16
+ // still counts. E71's rule — err toward more people — holds.
17
+ // 4. Only proven accounts are on the team list (what `yad member list` and the standup read).
18
+ // 5. A member is ACTIVE while they commit or approve within the TTL (90 days by default,
19
+ // `members.ttlDays` in the Product settings). After it, the platform is asked whether they still
20
+ // have access: yes is IDLE, no is LEFT (off the team list). Their next commit or approval makes
21
+ // them active again. The status is worked out on every read; nothing runs in the background, and a
22
+ // file is never deleted by the TTL — deleting it would lose the email ↔ login pairing.
23
+ // 6. EMAILS ARE STORED HASHED (`sha256:` of the trimmed, lower-cased address). A commit's email is
24
+ // hashed the same way when it is read, so matching still works and the file shows no address.
25
+ // 7. On a GitLab Product the pairings feed the standup only, never the gate count: GitLab's API cannot
26
+ // tell CI which account wrote a commit, so CI cannot prove an email belongs to the PR's author.
27
+ // 8. The CI check (`checks/member-check.mjs`) fails closed on GitLab when it has no API token.
28
+ //
29
+ // PROOF. An email joins a person when it is a VERIFIED email of their account on the platform (`user/emails`,
30
+ // asked with that account's own token), and it is the git `user.email` of the Product or of a registered repo
31
+ // on this machine. A second account joins when it lists one of those emails as verified too. On a verified
32
+ // GitHub Product, CI proves it again on the PR: the file's account must be the PR's author, and each email
33
+ // the file adds must be the author of a commit in the PR that GitHub itself attributes to that account.
34
+ // `yad member add` therefore writes one commit per email, each authored by that email.
35
+ //
36
+ // WRITING. `yad member add` builds its commits with a private index (`GIT_INDEX_FILE`) on top of the
37
+ // default branch, the way `yad capture` does, so the person's checkout, index and branch are never
38
+ // touched. It pushes `yad/member/<login>` and opens a PR/MR. Any step that fails leaves what was done,
39
+ // says what to run next, and never fails `yad join`.
40
+ import fs from 'node:fs';
41
+ import os from 'node:os';
42
+ import path from 'node:path';
43
+ import { createHash } from 'node:crypto';
44
+ import { spawnSync } from 'node:child_process';
45
+
46
+ import { c, log, ok, info, warn, fail, hand, run, readJSON, forTerminal, isPlainObject, asset } from './lib.mjs';
47
+ import { productConfigPath, isVerifiedLedger, SCHEMA_VERSION } from './manifest.mjs';
48
+ import { LOGIN_RE, hostFromGitUrl, plainHost } from './platform.mjs';
49
+ import { repoPathFromGitUrl, httpStatus } from './protection.mjs';
50
+ import { readRegistry, judgeRepo, runnable } from './workspace.mjs';
51
+ import { resolveDefaultBranch } from './productcommit.mjs';
52
+
53
+ export const MEMBERS_DIR = '.sdlc/members';
54
+ export const DEFAULT_TTL_DAYS = 90;
55
+ export const MEMBER_BRANCH_PREFIX = 'yad/member';
56
+ const PLATFORMS = ['github', 'gitlab'];
57
+ const DEFAULT_HOST = { github: 'github.com', gitlab: 'gitlab.com' };
58
+ const HASH_RE = /^sha256:[0-9a-f]{64}$/;
59
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
60
+ const TIMEOUT = 15_000;
61
+ const BRANCH_RE = /^\w[\w./-]*$/;
62
+
63
+ // ---- the record ---------------------------------------------------------------------------------
64
+
65
+ // The stored form of an email. Null for anything that is not a non-empty string with an `@` after its
66
+ // first character — git's `%ae` can be empty, and an empty email must never match another empty one.
67
+ export function hashEmail(email) {
68
+ if (typeof email !== 'string') return null;
69
+ const e = email.trim().toLowerCase();
70
+ if (e.indexOf('@', 1) < 0) return null;
71
+ return `sha256:${createHash('sha256').update(e).digest('hex')}`;
72
+ }
73
+
74
+ // A login yad may use as a file name and in an API path: the platform's own login shape, never a bot.
75
+ export const validLogin = (login) => typeof login === 'string' && LOGIN_RE.test(login) && !/\[bot\]$/i.test(login) && login.length <= 100;
76
+ export const memberFileName = (platform, login) => `${platform}-${String(login).toLowerCase()}.json`;
77
+ export const memberRel = (platform, login) => `${MEMBERS_DIR}/${memberFileName(platform, login)}`;
78
+ export const memberBranch = (login) => `${MEMBER_BRANCH_PREFIX}/${String(login).toLowerCase()}`;
79
+ const accountKey = (a) => `${a.platform}\0${String(a.host).toLowerCase()}\0${String(a.login).toLowerCase()}`;
80
+ export const accountLabel = (a) => `${a.platform === 'github' ? 'GitHub' : 'GitLab'} ${forTerminal(a.login)}${a.host !== DEFAULT_HOST[a.platform] ? ` on ${forTerminal(a.host)}` : ''}`;
81
+
82
+ // The Product's own platform, host and repo path, from its settings (`platform`, `git_url`), else from the
83
+ // clone's `origin`. `platform` is null on a Product with no platform: then nothing can be proven.
84
+ export function productIdentity(root, { runner = run, productConfig = readJSON(productConfigPath(root), null) } = {}) {
85
+ const platform = PLATFORMS.includes(productConfig?.platform) ? productConfig.platform : null;
86
+ let url = typeof productConfig?.git_url === 'string' ? productConfig.git_url : '';
87
+ if (!url) url = runner('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '';
88
+ const host = plainHost(hostFromGitUrl(url)) || (platform ? DEFAULT_HOST[platform] : null);
89
+ return { platform, host, repo: repoPathFromGitUrl(url), defaultBranch: typeof productConfig?.default_branch === 'string' ? productConfig.default_branch : null, verified: isVerifiedLedger(productConfig) };
90
+ }
91
+
92
+ // The TTL in days: `members.ttlDays` in the Product settings when it is a whole number from 1 to 3650.
93
+ export function ttlDays(productConfig) {
94
+ const v = productConfig?.members?.ttlDays;
95
+ return Number.isInteger(v) && v >= 1 && v <= 3650 ? v : DEFAULT_TTL_DAYS;
96
+ }
97
+
98
+ // One member file, checked. `{ record }` or `{ error }`. The file must be named by its account on the
99
+ // Product's platform and host — a copied file names another person, and is refused, not trusted.
100
+ export function readMemberFile(file, { platform, host } = {}) {
101
+ let text;
102
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { return { error: `cannot be read (${e.code || e.message})` }; }
103
+ let r;
104
+ try { r = JSON.parse(text); } catch { return { error: 'is not valid JSON' }; }
105
+ if (!isPlainObject(r)) return { error: 'is not a JSON object' };
106
+ if (!Array.isArray(r.accounts) || !r.accounts.length) return { error: 'has no "accounts" list' };
107
+ const accounts = [];
108
+ for (const a of r.accounts) {
109
+ if (!isPlainObject(a) || !PLATFORMS.includes(a.platform) || !plainHost(a.host) || !validLogin(a.login)) return { error: 'holds an account that is not { platform: github|gitlab, host, login }' };
110
+ if (a.id !== undefined && !(Number.isSafeInteger(a.id) && a.id > 0)) return { error: `holds an account whose "id" is not a positive whole number (${forTerminal(a.login)})` };
111
+ accounts.push({ platform: a.platform, host: a.host.toLowerCase(), login: a.login, ...(a.id !== undefined ? { id: a.id } : {}) });
112
+ }
113
+ if (new Set(accounts.map(accountKey)).size !== accounts.length) return { error: 'lists one account twice' };
114
+ const emails = Array.isArray(r.emails) ? r.emails : [];
115
+ if (!emails.every((h) => typeof h === 'string' && HASH_RE.test(h))) return { error: 'holds an email that is not stored as sha256:<64 hex>' };
116
+ const names = Array.isArray(r.names) ? r.names : [];
117
+ if (!names.every((n) => typeof n === 'string' && n.trim() && n.length <= 200)) return { error: 'holds a name that is not a short, non-empty text' };
118
+ if (r.joined !== undefined && !(typeof r.joined === 'string' && DATE_RE.test(r.joined))) return { error: 'has a "joined" date that is not YYYY-MM-DD' };
119
+ if (r.proved !== undefined && !(typeof r.proved === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(r.proved))) return { error: 'has a "proved" time that is not an ISO time in UTC' };
120
+ if (!platform) return { error: 'cannot be checked: the Product names no platform' };
121
+ const primary = accounts.find((a) => a.platform === platform && a.host === String(host).toLowerCase());
122
+ if (!primary) return { error: `has no ${platform} account on ${forTerminal(host)}, the Product's own platform` };
123
+ if (accounts.filter((a) => a.platform === platform && a.host === String(host).toLowerCase()).length > 1) return { error: `lists two accounts on ${forTerminal(host)} — one person has one account on the Product's own platform` };
124
+ if (path.basename(file) !== memberFileName(platform, primary.login)) return { error: `names ${forTerminal(primary.login)}, so it must be called ${memberFileName(platform, primary.login)}` };
125
+ return { record: { primary, accounts, emails: [...new Set(emails)], names: [...new Set(names.map((n) => n.trim()))], joined: r.joined ?? null, proved: r.proved ?? null } };
126
+ }
127
+
128
+ // Every member file. Returns { members, errors, duplicates }: `errors` are files that cannot be used (listed,
129
+ // never dropped), and `duplicates` the accounts and emails two files share. A shared account or email is
130
+ // left out of every pairing — both files keep their members, but neither joins anything through it, so
131
+ // the count over-counts, never under-counts.
132
+ export function readMembers(root, { identity = null } = {}) {
133
+ const id = identity || productIdentity(root);
134
+ const dir = path.join(root, MEMBERS_DIR);
135
+ const none = (errors) => ({ members: [], errors, duplicates: [], dupAccounts: new Set(), dupEmails: new Set(), identity: id });
136
+ // A LINK is never followed (E131 review 1): the member-check gate judges `.sdlc/members/` by the paths a
137
+ // PR changes, so a folder or file reached through a link would hold pairings no gate ever saw. The same
138
+ // refusal E115 makes for links under specs/.
139
+ const linked = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } };
140
+ if (linked(path.join(root, '.sdlc')) || linked(dir)) return none([{ rel: MEMBERS_DIR, error: `${MEMBERS_DIR} is reached through a link, so no member file is read` }]);
141
+ let names;
142
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort(); }
143
+ catch (e) { return none(e.code === 'ENOENT' ? [] : [{ rel: MEMBERS_DIR, error: `${MEMBERS_DIR} cannot be listed (${e.code || e.message})` }]); }
144
+ const members = [];
145
+ const errors = [];
146
+ for (const n of names) {
147
+ const rel = `${MEMBERS_DIR}/${n}`;
148
+ if (linked(path.join(dir, n))) { errors.push({ rel, error: `${rel} is a link — a member file is never read through one` }); continue; }
149
+ const { record, error } = readMemberFile(path.join(dir, n), id);
150
+ if (error) errors.push({ rel, error: `${rel} ${error}` });
151
+ else members.push({ rel, ...record });
152
+ }
153
+ // WHO WINS when two files name one account (E131 review 6). A member's account on the Product's own
154
+ // platform is proven by CI (it is the PR's author); their OTHER accounts are not. So:
155
+ // - one primary account, or one email, in two files: a real conflict — neither file joins it;
156
+ // - an other account that is someone's PRIMARY: the primary's owner keeps it, the claim is ignored;
157
+ // - an other account two files claim: disputed — matched by nobody until one file drops it.
158
+ // A claim that is ignored never blocks the person it names: their own file is still read in full.
159
+ const seenAcc = new Map();
160
+ const seenPrimary = new Map();
161
+ const seenMail = new Map();
162
+ for (const m of members) {
163
+ seenPrimary.set(accountKey(m.primary), [...(seenPrimary.get(accountKey(m.primary)) || []), m.rel]);
164
+ for (const a of m.accounts) if (accountKey(a) !== accountKey(m.primary)) seenAcc.set(accountKey(a), [...(seenAcc.get(accountKey(a)) || []), m.rel]);
165
+ for (const h of m.emails) seenMail.set(h, [...(seenMail.get(h) || []), m.rel]);
166
+ }
167
+ const dupAccounts = new Set([...seenPrimary].filter(([, rels]) => rels.length > 1).map(([k]) => k));
168
+ const disputed = new Set([...seenAcc].filter(([k, rels]) => rels.length > 1 || seenPrimary.has(k)).map(([k]) => k));
169
+ const dupEmails = new Set([...seenMail].filter(([, rels]) => rels.length > 1).map(([h]) => h));
170
+ const label = (k) => { const [p, h, l] = k.split('\0'); return accountLabel({ platform: p, host: h, login: l }); };
171
+ const duplicates = [
172
+ ...[...seenPrimary].filter(([k]) => dupAccounts.has(k)).map(([k, rels]) => `the ${label(k)} account is in ${rels.join(' and ')}`),
173
+ ...[...seenMail].filter(([h]) => dupEmails.has(h)).map(([, rels]) => `one email is in ${rels.join(' and ')}`),
174
+ ...[...disputed].map((k) => `the ${label(k)} account is claimed by ${[...(seenPrimary.get(k) || []), ...seenAcc.get(k)].join(' and ')}${seenPrimary.has(k) ? ` — it is ${seenPrimary.get(k)[0]}'s own, so the other claim is ignored` : ' — neither is matched until one file drops it'}`),
175
+ ];
176
+ for (const m of members) m.matchLogins = m.accounts.filter((a) => accountKey(a) === accountKey(m.primary) || !disputed.has(accountKey(a))).map((a) => a.login.toLowerCase());
177
+ return { members, errors, duplicates, dupAccounts, dupEmails, disputed, identity: id };
178
+ }
179
+
180
+ // The pairings the GATE COUNT may use: a commit email's hash → the login that approves on the Product.
181
+ // Only on a GitHub Product (decision 7), only a member's account on the Product's own host, and never an
182
+ // account or email two files share. An unreadable file adds nothing — the count then over-counts.
183
+ // `anyPlatform`: the same pairing for a REPORT (`yad usage`, the standup), where GitLab's pairings count too.
184
+ export function gateMemberMap(root, { read = null, anyPlatform = false } = {}) {
185
+ const map = new Map();
186
+ let got;
187
+ try { got = read || readMembers(root); } catch { return map; }
188
+ if (got.identity?.platform !== 'github' && !(anyPlatform && got.identity?.platform)) return map;
189
+ // The GATE COUNT trusts a pairing only where the member-check gate judged it (review 2, finding 2): a
190
+ // verified Product with the shipped gate installed and run by its checks workflow. Anywhere else a file
191
+ // is its owner's statement, and a statement must never make the count smaller.
192
+ const judged = anyPlatform ? null : judgedMemberFiles(root, got.identity);
193
+ if (judged && !judged.files.size) return map;
194
+ for (const m of got.members) {
195
+ if (got.dupAccounts.has(accountKey(m.primary))) continue;
196
+ if (judged && !judged.files.has(m.rel)) continue;
197
+ for (const h of m.emails) if (!got.dupEmails.has(h)) map.set(h, m.primary.login);
198
+ }
199
+ return map;
200
+ }
201
+
202
+ // The default branch AS ORIGIN SAYS IT (review 5): origin's own HEAD when the clone knows it; otherwise the
203
+ // name in the settings, but only when origin's copy of those settings, on that branch, names it too — a
204
+ // local edit to the settings must not point the count at a branch someone pushed with a made-up history.
205
+ // Null when it cannot be told. Returns the plain branch name.
206
+ export function originDefault(root) {
207
+ const g = gitAt(root);
208
+ const head = g(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
209
+ const fromHead = head.ok ? head.out.replace(/^origin\//, '') : '';
210
+ const local = readJSON(productConfigPath(root), null)?.default_branch;
211
+ // origin/HEAD is recorded at clone time and never follows a rename (review 6): when it and the settings
212
+ // both name a branch and disagree, nothing is trusted — the count goes up, never down.
213
+ if (fromHead && typeof local === 'string' && local && local !== fromHead) return null;
214
+ if (fromHead && BRANCH_RE.test(fromHead) && !fromHead.includes('..')) return fromHead;
215
+ const name = typeof local === 'string' && local ? local : 'main';
216
+ if (!BRANCH_RE.test(name) || name.includes('..')) return null;
217
+ const read = (f) => { const r = g(['show', `refs/remotes/origin/${name}:./${f}`]); return r.ok ? parse(r.out) : undefined; };
218
+ const there = read('.sdlc/product.json') ?? read('.sdlc/hub.json');
219
+ if (there === undefined) return null;
220
+ return (typeof there?.default_branch === 'string' && there.default_branch ? there.default_branch : 'main') === name ? name : null;
221
+ }
222
+
223
+ // The member-check gate and its workflow, per platform: the shipped copy and where it is installed.
224
+ const GATE_FILES = (platform) => [
225
+ ['skills/yad-checks/templates/checks/member-check.mjs', 'checks/member-check.mjs'],
226
+ platform === 'gitlab'
227
+ ? ['skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml', '.gitlab/ci/yad-product-checks.yml']
228
+ : ['skills/yad-checks/templates/github/yad-product-checks.yml', '.github/workflows/yad-product-checks.yml'],
229
+ ];
230
+ // Line endings never decide (a Windows checkout writes CRLF): only the text.
231
+ const sameText = (a, b) => a.replace(/\r\n/g, '\n') === b.replace(/\r\n/g, '\n');
232
+
233
+ // Which member files the GATE COUNT may trust: the set of their paths, or an empty set. Read entirely from
234
+ // the default branch AS ORIGIN HAS IT (review 4) — never the files on disk or a commit nobody pushed.
235
+ // 1. LIVE: the Product is verified, and at origin/<default> both the gate script and the workflow that runs
236
+ // it are exactly the copies this yadflow ships (review 3: a commented-out line or `if: false` job would
237
+ // mention the gate without running it).
238
+ // 2. SINCE: the last first-parent commit on origin/<default> that changed EITHER file in any way (review 4:
239
+ // one PR could switch the gate off and add a forged file, judged by its own disabled gate, and a later
240
+ // PR restore it). From that commit on, every change that reached the branch was judged by the gate as
241
+ // shipped. So every `yad update` that changes either file resets trust — each member runs `yad member
242
+ // add` again. That only ever makes the count higher, the direction E71 allows.
243
+ // 3. JUDGED: a member file changed on the first-parent line strictly after SINCE, and the same on disk as
244
+ // on origin/<default> (an edit or a local commit is nobody's proof).
245
+ // Anything that cannot be read — no origin branch, a shallow clone, git missing — trusts nothing.
246
+ export function judgedMemberFiles(root, identity) {
247
+ const none = { live: false, files: new Set(), since: null };
248
+ if (!identity?.verified || !PLATFORMS.includes(identity.platform)) return none;
249
+ const g = gitAt(root);
250
+ const branch = originDefault(root);
251
+ if (!branch) return none;
252
+ const base = `refs/remotes/origin/${branch}`;
253
+ if (!g(['rev-parse', '--verify', '-q', `${base}^{commit}`]).ok) return none;
254
+ for (const [src, dest] of GATE_FILES(identity.platform)) {
255
+ const at = g(['show', `${base}:./${dest}`]);
256
+ let shipped;
257
+ try { shipped = fs.readFileSync(asset(src), 'utf8'); } catch { return none; }
258
+ if (!at.ok || !sameText(at.out, shipped.trim())) return none;
259
+ }
260
+ const since = g(['log', '--first-parent', '-1', '--format=%H', base, '--', ...GATE_FILES(identity.platform).map(([, d]) => d)]).out.trim();
261
+ if (!since) return { ...none, live: true };
262
+ // One walk for every member file (review 4: not three git runs per file on every gate command).
263
+ const changed = g(['log', '--first-parent', '--relative', '--name-only', '--format=', `${since}..${base}`, '--', MEMBERS_DIR]);
264
+ if (!changed.ok) return { ...none, live: true };
265
+ // Each candidate must be on disk byte for byte as origin holds it (review 5): an untracked copy, or an edit
266
+ // git was told to ignore (`--assume-unchanged`), is not what the gate judged. One git run for them all.
267
+ const cands = [...new Set(changed.out.split('\n').filter(Boolean))];
268
+ const batch = spawnSync('git', ['cat-file', '--batch'], { cwd: root, input: cands.map((f) => `${base}:./${f}\n`).join(''), maxBuffer: 1 << 28 });
269
+ const out = batch.status === 0 ? batch.stdout : Buffer.alloc(0);
270
+ const files = new Set();
271
+ let at = 0;
272
+ for (const f of cands) {
273
+ const nl = out.indexOf(10, at);
274
+ if (nl < 0) break;
275
+ const header = out.subarray(at, nl).toString();
276
+ at = nl + 1;
277
+ const m = header.match(/^[0-9a-f]+ (\w+) (\d+)$/);
278
+ if (!m) continue; // `<name> missing`: deleted at origin, and no body follows
279
+ const blob = out.subarray(at, at + Number(m[2]));
280
+ at += Number(m[2]) + 1; // every body is skipped, whatever its type (review 6)
281
+ if (m[1] !== 'blob') continue;
282
+ let disk;
283
+ try { if (fs.lstatSync(path.join(root, f)).isSymbolicLink()) continue; disk = fs.readFileSync(path.join(root, f)); } catch { continue; }
284
+ if (sameText(disk.toString('utf8'), blob.toString('utf8'))) files.add(f);
285
+ }
286
+ return { live: true, files, since };
287
+ }
288
+
289
+ // Is the gate live at origin's default branch? (doctor and `yad member add` say so when it is not.)
290
+ export const memberGateLive = (root, identity) => judgedMemberFiles(root, identity).live;
291
+
292
+ // Does this piece of evidence (a commit, an approval, a ship) belong to this member? By any of their
293
+ // accounts' logins, any email, or any git name. The standup and the status read it; the gate count never
294
+ // does (names prove nothing, and other accounts are not proven on the PR).
295
+ export function memberMatches(member, e) {
296
+ const login = String(e?.login || '').toLowerCase();
297
+ const logins = member.matchLogins || member.accounts.map((a) => a.login.toLowerCase());
298
+ if (login && logins.includes(login)) return true;
299
+ if (e?.emailHash && member.emails.includes(e.emailHash)) return true;
300
+ const name = String(e?.name || '').trim().toLowerCase();
301
+ return !!name && member.names.some((n) => n.toLowerCase() === name);
302
+ }
303
+
304
+ // ---- the platform: who is logged in, and what they can prove -------------------------------------
305
+
306
+ // The GitHub accounts gh holds, per host. `gh auth status` has no --json (gh 2.79), so its lines are read:
307
+ // `Logged in to <host> account <login> (…)` since gh 2.40, `Logged in to <host> as <login> (…)` before.
308
+ export function ghAccounts({ runner = run, env = process.env } = {}) {
309
+ const r = runner('gh', ['auth', 'status'], { env, timeout: TIMEOUT });
310
+ const text = `${r.stdout || ''}\n${r.stderr || ''}`;
311
+ const out = [];
312
+ // Each account's block may say `- Active account: true|false` (gh 2.40+); before that there was one per host.
313
+ const blocks = text.split(/(?=Logged in to )/);
314
+ for (const b of blocks) {
315
+ const m = b.match(/^Logged in to (\S+) (?:account|as) (\S+)/);
316
+ if (!m) continue;
317
+ const host = plainHost(m[1]?.toLowerCase());
318
+ const login = m[2]?.replace(/[()]/g, '');
319
+ const active = !/Active account:\s*false/i.test(b);
320
+ if (host && validLogin(login) && !out.some((a) => a.host === host && a.login === login)) out.push({ platform: 'github', host, login, active });
321
+ }
322
+ return out;
323
+ }
324
+
325
+ // The environment that makes gh answer as ONE of its accounts: its own token, for github.com and for an
326
+ // Enterprise host alike. Null when gh will not hand the token out.
327
+ function ghEnvFor(account, { runner = run, env = process.env } = {}) {
328
+ const t = runner('gh', ['auth', 'token', '-h', account.host, '-u', account.login], { env, timeout: TIMEOUT });
329
+ const token = (t.stdout || '').trim();
330
+ if (!t.ok || !token) return null;
331
+ return { ...env, GH_TOKEN: token, GH_ENTERPRISE_TOKEN: token, GH_PROMPT_DISABLED: '1' };
332
+ }
333
+
334
+ const parse = (s) => { try { return JSON.parse(s); } catch { return null; } };
335
+
336
+ // One account's id and VERIFIED emails, asked of the platform with that account's own token.
337
+ // Returns { account: {platform, host, login, id}, emails: [plain addresses], problem? }.
338
+ export function accountProof(account, { runner = run, env = process.env } = {}) {
339
+ if (account.platform === 'github') {
340
+ const genv = ghEnvFor(account, { runner, env });
341
+ if (!genv) return { account, emails: [], problem: `gh would not give the token of ${accountLabel(account)}` };
342
+ const u = runner('gh', ['api', '--hostname', account.host, 'user'], { env: genv, timeout: TIMEOUT });
343
+ const user = u.ok ? parse(u.stdout) : null;
344
+ if (!user?.login || !validLogin(user.login)) return { account, emails: [], problem: `${accountLabel(account)}: the platform did not say who this token belongs to (a GitHub App token has no user)` };
345
+ const proved = { platform: 'github', host: account.host, login: user.login, ...(Number.isSafeInteger(user.id) ? { id: user.id } : {}) };
346
+ const e = runner('gh', ['api', '--hostname', account.host, 'user/emails', '--paginate', '--jq', '.[] | select(.verified == true) | .email'], { env: genv, timeout: TIMEOUT });
347
+ if (!e.ok) {
348
+ const code = httpStatus(e);
349
+ return { account: proved, emails: [], problem: code === 404 || code === 403
350
+ ? `${accountLabel(proved)}: the token cannot read its email addresses — run \`gh auth refresh -h ${account.host} -s user:email\` (a fine-grained token needs "Email addresses: read")`
351
+ : `${accountLabel(proved)}: its email addresses could not be read${code ? ` (HTTP ${code})` : ''}` };
352
+ }
353
+ // One verified address per line, across every page. GitHub does not list the account's own noreply
354
+ // address (E131 review 8), yet it attributes a commit authored by it to the account — so it is proof.
355
+ const emails = (e.stdout || '').split('\n').map((l) => l.trim()).filter((l) => l.includes('@'));
356
+ const noreply = `users.noreply.${account.host === 'github.com' ? 'github.com' : account.host}`;
357
+ emails.push(`${proved.login}@${noreply}`, ...(proved.id ? [`${proved.id}+${proved.login}@${noreply}`] : []));
358
+ return { account: proved, emails };
359
+ }
360
+ // GitLab: one account per host, asked through glab's own login for that host.
361
+ const u = runner('glab', ['api', '--hostname', account.host, 'user'], { env, timeout: TIMEOUT });
362
+ const user = u.ok ? parse(u.stdout) : null;
363
+ if (!user?.username || !validLogin(user.username)) return { account, emails: [], problem: `GitLab on ${forTerminal(account.host)}: not logged in (\`glab auth login --hostname ${account.host}\`)` };
364
+ const proved = { platform: 'gitlab', host: account.host, login: user.username, ...(Number.isSafeInteger(user.id) ? { id: user.id } : {}) };
365
+ // The primary email of a GitLab account is always confirmed, and `commit_email` can only be one of the
366
+ // confirmed ones; `user/emails` lists the secondary ones, each with `confirmed_at` once confirmed.
367
+ const emails = [user.email, user.commit_email].filter((x) => typeof x === 'string' && x.includes('@'));
368
+ const e = runner('glab', ['api', '--hostname', account.host, 'user/emails'], { env, timeout: TIMEOUT });
369
+ if (e.ok) for (const x of parse(e.stdout) || []) if (x && typeof x.email === 'string' && x.confirmed_at) emails.push(x.email);
370
+ return { account: proved, emails: [...new Set(emails)], ...(e.ok ? {} : { problem: `${accountLabel(proved)}: its other email addresses could not be read — the token needs the read_user scope` }) };
371
+ }
372
+
373
+ // The git user.email and user.name of the Product and of every registered repo on this machine, each
374
+ // judged before git runs in it (E81 — a registered folder is shared content). Names are kept as typed.
375
+ export function gitIdentities(root, { runner = run, repos = [] } = {}) {
376
+ const emails = new Set();
377
+ const names = new Set();
378
+ for (const dir of [root, ...repos]) {
379
+ const e = (runner('git', ['config', 'user.email'], { cwd: dir }).stdout || '').trim();
380
+ const n = (runner('git', ['config', 'user.name'], { cwd: dir }).stdout || '').trim();
381
+ if (e) emails.add(e);
382
+ if (n) names.add(n);
383
+ }
384
+ return { emails: [...emails], names: [...names] };
385
+ }
386
+
387
+ // The record to write for the person on this machine, or { problem } with the sentence that says what to
388
+ // fix. Pure over its inputs, so every rule is testable without a platform.
389
+ // identity the Product's platform and host
390
+ // proofs accountProof() of every account this machine is logged in to
391
+ // git gitIdentities()
392
+ export function buildRecord({ identity, proofs, git, today, existing = null, proved = `${today}T00:00:00Z` }) {
393
+ if (!identity?.platform) return { problem: 'the Product names no platform, so no account can be proven — a member file needs one' };
394
+ const host = String(identity.host).toLowerCase();
395
+ const onProduct = proofs.filter((p) => p.account.platform === identity.platform && p.account.host === host);
396
+ if (!onProduct.length) return { problem: `not logged in to ${identity.platform === 'github' ? 'GitHub' : 'GitLab'} on ${forTerminal(host)}, the Product's platform`, login: true };
397
+ const gitHashes = new Map(git.emails.map((e) => [hashEmail(e), e]).filter(([h]) => h));
398
+ const verifiedOn = (p) => new Set(p.emails.map(hashEmail).filter(Boolean));
399
+ // One account on the Product's own platform: the one whose verified emails include a git email here.
400
+ // Two logged-in accounts that both qualify cannot be told apart — the person picks with `gh auth switch`.
401
+ const matching = onProduct.filter((p) => [...verifiedOn(p)].some((h) => gitHashes.has(h)));
402
+ if (!matching.length) {
403
+ const tried = onProduct.map((p) => accountLabel(p.account)).join(', ');
404
+ const why = onProduct.map((p) => p.problem).filter(Boolean);
405
+ return { problem: `no git email here (${git.emails.length ? `${git.emails.length} found` : 'git has no user.email'}) is a verified email of ${tried}${why.length ? ` — ${why.join('; ')}` : ''}`,
406
+ hint: 'add and verify that email on the platform, or set git user.email to one the account has verified, then run `yad member add` again' };
407
+ }
408
+ if (matching.length > 1) return { problem: `more than one ${identity.platform} account on ${forTerminal(host)} is logged in and has verified a git email here (${matching.map((p) => p.account.login).join(', ')})`, hint: 'keep one (`gh auth switch`), then run `yad member add` again' };
409
+ const primary = matching[0];
410
+ const emails = new Set([...verifiedOn(primary)].filter((h) => gitHashes.has(h)));
411
+ // Other accounts: those that share a verified email with the ones above.
412
+ const others = proofs.filter((p) => p !== primary && !(p.account.platform === identity.platform && p.account.host === host)
413
+ && [...verifiedOn(p)].some((h) => emails.has(h)));
414
+ // Emails come from the PRIMARY account only (E131 review 3): CI proves an email by the commit GitHub gives
415
+ // to the PR's author, and an email only another account verified would be given to nobody — the PR yad
416
+ // opened would fail its own gate. Another account joins through an email the primary verified too.
417
+ const skipped = proofs.filter((p) => p !== primary && !others.includes(p)).map((p) => accountLabel(p.account));
418
+ const unproven = [...gitHashes].filter(([h]) => !emails.has(h)).length;
419
+ const keep = existing && existing.primary.login.toLowerCase() === primary.account.login.toLowerCase() ? existing : null;
420
+ const accounts = [primary.account, ...others.map((p) => p.account)];
421
+ for (const a of keep?.accounts || []) if (!accounts.some((b) => accountKey(b) === accountKey(a))) accounts.push(a);
422
+ // EVERY change re-proves EVERY email (E131 review 5): the gate proves each email in a changed file, so a
423
+ // file cannot carry an email in from a time the gate was off. An email the file already holds is kept
424
+ // only while the account still verifies it — its address comes from the platform's answer, so it can be
425
+ // proven again from any machine — and dropped once it is not.
426
+ const plain = new Map(primary.emails.map((e) => [hashEmail(e), e]).filter(([h]) => h));
427
+ // A partial answer is not "no longer verified" (review 6): when the platform could not list every email,
428
+ // an email the file holds is not dropped on that guess — the run stops and says what to fix.
429
+ const unlisted = (keep?.emails || []).filter((h) => !plain.has(h));
430
+ if (primary.problem && unlisted.length) return { problem: `${unlisted.length} email(s) in your member file could not be checked: ${primary.problem}`, hint: 'fix that, then run `yad member add` again — nothing was changed' };
431
+ for (const h of keep?.emails || []) if (plain.has(h)) emails.add(h);
432
+ const record = {
433
+ accounts,
434
+ emails: [...emails].sort(),
435
+ names: [...new Set([...(keep?.names || []), ...git.names])].sort(),
436
+ joined: keep?.joined || today,
437
+ // A moment, not a day (review 6): a second run on the same day must still change the file, or its PR
438
+ // would hold no change for the gate to judge.
439
+ proved,
440
+ };
441
+ const dropped = (keep?.emails || []).filter((h) => !emails.has(h)).length;
442
+ return { record, primary: primary.account, skipped, unproven, dropped, plainByHash: new Map(record.emails.map((h) => [h, plain.get(h)])), newEmails: record.emails };
443
+ }
444
+
445
+ // The bytes of a member file, as `writeJSON` would write them: the shape stamp first, two-space JSON.
446
+ export const memberJSON = (record) => `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, ...record }, null, 2)}\n`;
447
+
448
+ // ---- writing it: commits on a branch, never the checkout --------------------------------------------
449
+
450
+ function gitAt(root, extra = null) {
451
+ return (args, input = null) => {
452
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28, env: extra ? { ...process.env, ...extra } : process.env, ...(input !== null ? { input } : {}) });
453
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
454
+ };
455
+ }
456
+
457
+ // One commit per email, for EVERY email in the file (CI's proof: GitHub attributes each to the account that
458
+ // verified that email, and the gate asks it of every email in a changed file). The first commit writes the
459
+ // file with the first email, each later one adds the next; the last holds them all.
460
+ // Built on `base` with a private index. Signed when git signs (`commit.gpgsign`), as a person's commit is.
461
+ // Returns { branch, head, commits } or { error }.
462
+ export function commitMember(root, { rel, record, plainByHash, base, name, login, subject }) {
463
+ const git = gitAt(root);
464
+ const prefix = git(['rev-parse', '--show-prefix']).out;
465
+ const target = `${prefix}${rel}`;
466
+ const sign = git(['config', '--bool', 'commit.gpgsign']).out === 'true';
467
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yad-member-'));
468
+ const idx = gitAt(root, { GIT_INDEX_FILE: path.join(tmp, 'index') });
469
+ try {
470
+ if (!idx(['read-tree', base]).ok) return { error: `could not read ${base}` };
471
+ const steps = record.emails.map((h, i) => ({ emails: record.emails.slice(0, i + 1), email: plainByHash.get(h) }));
472
+ if (!steps.length || steps.some((x) => !x.email)) return { error: 'every email needs its own address to author its proof commit' };
473
+ let parent = base;
474
+ const commits = [];
475
+ for (const [i, s] of steps.entries()) {
476
+ const blob = idx(['hash-object', '-w', '--stdin'], memberJSON({ ...record, emails: s.emails }));
477
+ if (!blob.ok) return { error: `could not write the file: ${blob.err}` };
478
+ if (!idx(['update-index', '--add', '--cacheinfo', `100644,${blob.out},${target}`]).ok) return { error: 'could not stage the file' };
479
+ const tree = idx(['write-tree']);
480
+ if (!tree.ok) return { error: `could not write the tree: ${tree.err}` };
481
+ const msg = `${subject}${steps.length > 1 ? ` (${i + 1}/${steps.length})` : ''}\n\nAdds ${login}'s member file: one job, identity — it joins a git email to a platform account.\n`;
482
+ const author = gitAt(root, { GIT_AUTHOR_NAME: name || login, GIT_AUTHOR_EMAIL: s.email || '' });
483
+ const commit = author(['commit-tree', ...(sign ? ['-S'] : []), tree.out, '-p', parent, '-F', '-'], msg);
484
+ if (!commit.ok) return { error: `could not commit${sign ? ' (git signs commits here — is the signing key available?)' : ''}: ${commit.err}` };
485
+ parent = commit.out;
486
+ commits.push(parent);
487
+ }
488
+ const branch = memberBranch(login);
489
+ const moved = git(['update-ref', '-m', 'yad member add', `refs/heads/${branch}`, parent]);
490
+ if (!moved.ok) return { error: `could not move ${branch}: ${moved.err}` };
491
+ return { branch, head: parent, commits };
492
+ } finally {
493
+ fs.rmSync(tmp, { recursive: true, force: true });
494
+ }
495
+ }
496
+
497
+ const PR_BODY = (login, emails) => `## Summary
498
+
499
+ Adds the team member file for @${login}. It records one thing — identity: which platform accounts and which git
500
+ emails (stored hashed) belong to one person. It grants no access, holds no role and requests no reviewer.
501
+
502
+ ## Impact & Risk
503
+
504
+ Risk level: low
505
+
506
+ - The active-people count joins this person's commits to their approvals (one person, not two).
507
+ - ${emails} email(s); each is added by its own commit, authored by that email, so CI can check that the platform
508
+ attributes it to @${login}.
509
+
510
+ ## Checklist
511
+
512
+ - [x] Written by \`yad member add\` from the platform's own answer (verified emails only)
513
+ - [x] Only my own member file changes
514
+ `;
515
+
516
+ // Push the branch and open the PR/MR. Never prompts (a clone that needs a password says so instead).
517
+ export function publishMember(root, { branch, base, login, platform, host, title, emails, runner = run }) {
518
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
519
+ // The branch is yad's own and is rebuilt from the default branch on every run (E131 review 4), so a run
520
+ // after an earlier one replaces it — with a lease on what origin held when it was read, never blindly.
521
+ // The lease names the exact commit origin holds (empty: the branch must not exist yet), read just now from
522
+ // origin itself — a single-branch clone has no remote-tracking ref for git to find on its own.
523
+ const remote = runner('git', ['ls-remote', 'origin', `refs/heads/${branch}`], { cwd: root, env, timeout: 60_000 });
524
+ if (!remote.ok) return { pushed: false, error: `could not read origin: ${forTerminal(remote.stderr || 'git ls-remote failed')}` };
525
+ const held = (remote.stdout || '').split(/\s/)[0] || '';
526
+ const push = runner('git', ['push', '--no-verify', '--quiet', `--force-with-lease=refs/heads/${branch}:${held}`, '-u', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { cwd: root, env, timeout: 60_000 });
527
+ if (!push.ok) return { pushed: false, error: `could not push ${branch}: ${forTerminal(push.stderr || 'git push failed')}` };
528
+ const body = PR_BODY(login, emails);
529
+ const r = platform === 'github'
530
+ ? runner('gh', ['pr', 'create', '--head', branch, '--base', base, '--title', title, '--body', body], { cwd: root, env: { ...process.env, ...(host && host !== 'github.com' ? { GH_HOST: host } : {}) }, timeout: 60_000 })
531
+ : runner('glab', ['mr', 'create', '--source-branch', branch, '--target-branch', base, '--title', title, '--description', body, '--yes'], { cwd: root, timeout: 60_000 });
532
+ // One already open for the branch is the one this push updated.
533
+ if (!r.ok && /already exists/i.test(`${r.stderr} ${r.stdout}`)) {
534
+ const url = `${r.stderr} ${r.stdout}`.match(/https?:\/\/\S+/)?.[0] || null;
535
+ return { pushed: true, url, existing: true };
536
+ }
537
+ if (!r.ok) return { pushed: true, error: `pushed ${branch}, but could not open the ${platform === 'github' ? 'PR' : 'MR'}: ${forTerminal(r.stderr || 'failed')}` };
538
+ const url = (r.stdout || '').split('\n').map((l) => l.trim()).find((l) => /^https?:\/\//.test(l)) || null;
539
+ return { pushed: true, url };
540
+ }
541
+
542
+ // ---- status: active / idle / left / unknown -------------------------------------------------------
543
+
544
+ // The last day each member did something the count calls activity (a commit or an approval), from the
545
+ // evidence `peopleEvidence` reads. Null when nothing was seen in what was read.
546
+ export function lastActive(member, events) {
547
+ let last = null;
548
+ for (const e of events) if (e?.ts && memberMatches(member, e) && (!last || e.ts > last)) last = String(e.ts).slice(0, 10);
549
+ return last;
550
+ }
551
+
552
+ // Does the account still have access to the Product's repo? 'yes' | 'no' | 'unknown'. Only a clear answer
553
+ // from the platform says 'no': an error, a timeout, a 403 (no right to ask, an SSO org) is 'unknown' — a
554
+ // person is never marked as gone on a guess.
555
+ export function accessCheck(identity, member, { runner = run } = {}) {
556
+ const a = member.primary;
557
+ if (!identity?.repo) return 'unknown';
558
+ if (a.platform === 'github') {
559
+ const r = runner('gh', ['api', '--hostname', a.host, `repos/${identity.repo}/collaborators/${a.login}/permission`, '--jq', '.permission'], { timeout: TIMEOUT });
560
+ if (!r.ok) return 'unknown';
561
+ const perm = r.stdout.trim();
562
+ if (perm === 'none') return 'no';
563
+ if (['admin', 'maintain', 'write', 'triage'].includes(perm)) return 'yes';
564
+ // `read` on a PUBLIC repo is everyone's (E131 review 7): it says nothing about this person.
565
+ if (perm === 'read') return runner('gh', ['api', '--hostname', a.host, `repos/${identity.repo}`, '--jq', '.private'], { timeout: TIMEOUT }).stdout?.trim() === 'true' ? 'yes' : 'unknown';
566
+ return 'unknown';
567
+ }
568
+ if (!Number.isSafeInteger(a.id)) return 'unknown';
569
+ const r = runner('glab', ['api', '--hostname', a.host, `projects/${encodeURIComponent(identity.repo)}/members/all/${a.id}`], { timeout: TIMEOUT });
570
+ if (r.ok) return 'yes';
571
+ // A 404 answers "not a member" only when the project itself can be read.
572
+ if (httpStatus(r) === 404 && runner('glab', ['api', '--hostname', a.host, `projects/${encodeURIComponent(identity.repo)}`], { timeout: TIMEOUT }).ok) return 'no';
573
+ return 'unknown';
574
+ }
575
+
576
+ // The access answers of today, per clone, kept in git's common folder (never committed; every worktree of
577
+ // one clone shares it). One read per member per day.
578
+ function accessCache(root) {
579
+ const r = gitAt(root)(['rev-parse', '--git-common-dir']);
580
+ if (!r.ok) return null;
581
+ const dir = path.resolve(root, r.out);
582
+ return path.join(dir, 'yad-members-access.json');
583
+ }
584
+
585
+ // Every member's status. `events` are the activity records (commit / approval); `today` a YYYY-MM-DD.
586
+ // `access` is called only for a member whose TTL ran out, and only once a day (cached).
587
+ export function memberStatuses(root, members, { events, today, ttl = DEFAULT_TTL_DAYS, identity, access = accessCheck, offline = false } = {}) {
588
+ const cutoff = new Date(Date.parse(`${today}T00:00:00Z`) - ttl * 86400000).toISOString().slice(0, 10);
589
+ const file = offline ? null : accessCache(root);
590
+ let cache = {};
591
+ try { const c0 = file ? JSON.parse(fs.readFileSync(file, 'utf8')) : null; if (c0?.date === today && isPlainObject(c0.answers)) cache = c0.answers; } catch { /* none yet */ }
592
+ let wrote = false;
593
+ const out = members.map((m) => {
594
+ const last = lastActive(m, events);
595
+ if (last && last >= cutoff) return { ...m, status: 'active', lastActive: last };
596
+ const key = accountKey(m.primary);
597
+ let answer = offline ? 'unknown' : cache[key];
598
+ // An `unknown` is not kept: one network failure must not hide the answer for a day (review 7).
599
+ if (!answer) { answer = access(identity, m); if (answer !== 'unknown') { cache[key] = answer; wrote = true; } }
600
+ return { ...m, status: answer === 'yes' ? 'idle' : answer === 'no' ? 'left' : 'unknown', lastActive: last };
601
+ });
602
+ if (wrote && file) { try { fs.writeFileSync(file, `${JSON.stringify({ date: today, answers: cache })}\n`); } catch { /* a cache, nothing more */ } }
603
+ return out;
604
+ }
605
+
606
+ // The registered repos on this machine that git may run in (judged first, E81), and the GitLab hosts they
607
+ // live on — where `yad member add` looks for the person's other git emails and accounts.
608
+ export function registeredRepos(root, { runner = run } = {}) {
609
+ const { registry } = readRegistry(root);
610
+ const dirs = [];
611
+ const hosts = new Set();
612
+ for (const r of registry.repos) {
613
+ const j = judgeRepo(root, r);
614
+ if (j.state !== 'present' || !runnable(j)) continue;
615
+ const dir = path.resolve(root, r.path);
616
+ dirs.push(dir);
617
+ const host = plainHost(hostFromGitUrl(runner('git', ['remote', 'get-url', 'origin'], { cwd: dir }).stdout || ''));
618
+ if (host && /gitlab/i.test(host)) hosts.add(host);
619
+ }
620
+ return { dirs, hosts: [...hosts] };
621
+ }
622
+
623
+ // The member files as a git ref holds them, read with the same rules as the folder on disk: each plain file
624
+ // is written into a scratch Product and `readMembers` judges it. A link or a folder in its place is skipped,
625
+ // as `readMembers` would refuse it.
626
+ export function membersAt(root, ref, identity) {
627
+ const git = gitAt(root);
628
+ const prefix = git(['rev-parse', '--show-prefix']).out;
629
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yad-members-at-'));
630
+ try {
631
+ const dir = path.join(tmp, MEMBERS_DIR);
632
+ fs.mkdirSync(dir, { recursive: true });
633
+ const ls = gitAt(root)(['ls-tree', '-z', `${ref}:${prefix}${MEMBERS_DIR}`]);
634
+ for (const line of ls.ok ? ls.out.split('\0').filter(Boolean) : []) {
635
+ const [meta, name] = line.split('\t');
636
+ if (!/^100(644|755) blob /.test(meta) || !name.endsWith('.json') || name.includes('/')) continue;
637
+ const blob = git(['cat-file', 'blob', meta.split(' ')[2]]);
638
+ if (blob.ok) fs.writeFileSync(path.join(dir, name), blob.out);
639
+ }
640
+ return readMembers(tmp, { identity });
641
+ } finally {
642
+ fs.rmSync(tmp, { recursive: true, force: true });
643
+ }
644
+ }
645
+
646
+ // ---- the commands -------------------------------------------------------------------------------
647
+
648
+ const refuser = () => (msg, hint) => { fail(msg); if (hint) hand(hint); process.exitCode = 1; };
649
+ const interactive = (env) => process.stdin.isTTY && process.stdout.isTTY && !env.SDLC_NONINTERACTIVE && !env.CI;
650
+
651
+ // `yad member add` — prove who is on this machine and write their member file on a branch with a PR/MR.
652
+ // `soft`: called by `yad join` — a problem is a warning and the next step, never a failure.
653
+ export async function runMemberAdd(root, { soft = false, noPush = false, runner = run, env = process.env, repos = undefined, today = new Date().toISOString().slice(0, 10), now = new Date().toISOString(), noLogin = false } = {}) {
654
+ const refuse = soft ? (msg, hint) => { warn(msg); if (hint) hand(hint); } : refuser();
655
+ log(c.bold(soft ? 'Team member' : '\nyad member add'));
656
+ if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/product.json here)', 'run it from the Product, or pass --dir');
657
+ // The switch every platform lookup obeys (offline work, and the test suite, which must never ask the
658
+ // developer's real account). With an injected runner the lookups are fakes, so it does not apply.
659
+ if (env.YAD_PLATFORM_LOGIN === '0' && runner === run) return refuse('platform lookups are off (YAD_PLATFORM_LOGIN=0), so no account can be proven', 'run `yad member add` without it');
660
+ const identity = productIdentity(root, { runner });
661
+ if (repos === undefined) repos = registeredRepos(root, { runner });
662
+ if (!identity.platform) return refuse('the Product names no platform — a member file needs an account the platform can prove', 'set the platform in the Product settings (`yad setup`), then run `yad member add`');
663
+ const cli = identity.platform === 'github' ? 'gh' : 'glab';
664
+ const discover = () => {
665
+ const accounts = ghAccounts({ runner, env });
666
+ // GitLab: one account per host. The Product's host, and every GitLab host a registered repo lives on.
667
+ const glHosts = new Set(identity.platform === 'gitlab' ? [identity.host] : []);
668
+ for (const h of (repos?.hosts || [])) glHosts.add(h);
669
+ for (const host of glHosts) accounts.push({ platform: 'gitlab', host, login: '(asked)' });
670
+ return accounts;
671
+ };
672
+ let accounts = discover();
673
+ const onProduct = () => accounts.some((a) => a.platform === identity.platform && a.host === identity.host && (a.platform === 'gitlab' || validLogin(a.login)));
674
+ if (identity.platform === 'github' && !onProduct()) {
675
+ if (interactive(env) && !noLogin) {
676
+ info(`not logged in to GitHub on ${identity.host} — starting \`gh auth login\``);
677
+ spawnSync('gh', ['auth', 'login', '--hostname', identity.host, '--scopes', 'user:email'], { stdio: 'inherit' });
678
+ accounts = discover();
679
+ }
680
+ if (!onProduct()) return refuse(`not logged in to GitHub on ${identity.host}`, `run \`gh auth login --hostname ${identity.host} --scopes user:email\`, then \`yad member add\``);
681
+ }
682
+ let proofs = accounts.map((a) => accountProof(a, { runner, env }));
683
+ if (identity.platform === 'gitlab' && !proofs.some((p) => p.account.platform === 'gitlab' && p.account.host === identity.host && validLogin(p.account.login))) {
684
+ if (interactive(env) && !noLogin) {
685
+ info(`not logged in to GitLab on ${identity.host} — starting \`glab auth login\``);
686
+ spawnSync('glab', ['auth', 'login', '--hostname', identity.host], { stdio: 'inherit' });
687
+ proofs = discover().map((a) => accountProof(a, { runner, env }));
688
+ }
689
+ }
690
+ proofs = proofs.filter((p) => validLogin(p.account.login));
691
+ const git = gitIdentities(root, { runner, repos: repos?.dirs || [] });
692
+ // Built on the default branch AS ORIGIN HAS IT, so the PR holds only this change — never on whatever the
693
+ // checkout is on (E131 review 5). The name comes from the shared settings, so only a plain branch name is
694
+ // used, and it reaches git inside a full refspec, never where it could be read as an option.
695
+ const g = gitAt(root);
696
+ // The same name the count reads trust from (review 6), after a fetch so origin's copy is current.
697
+ const wanted = resolveDefaultBranch((...a) => { const r = g(a); return { ok: r.ok, stdout: r.out }; }, readJSON(productConfigPath(root), null));
698
+ if (!BRANCH_RE.test(wanted) || wanted.includes('..')) return refuse(`the Product's default branch is not a plain branch name: ${forTerminal(wanted)}`, 'fix default_branch in the Product settings');
699
+ g(['fetch', '--quiet', 'origin', `+refs/heads/${wanted}:refs/remotes/origin/${wanted}`]);
700
+ const base = originDefault(root);
701
+ if (!base) return refuse(`origin's default branch cannot be told: this clone's origin/HEAD, the settings' default_branch (${forTerminal(wanted)}) and origin's own copy of the settings do not agree`, 'run `git remote set-head origin --auto`, pull, then run `yad member add` again');
702
+ if (!g(['rev-parse', '--verify', '-q', `refs/remotes/origin/${base}^{commit}`]).ok) return refuse(`could not read origin's ${base} — the member file is built on it`, 'check the clone can fetch from origin, then run `yad member add` again');
703
+ const baseRef = `refs/remotes/origin/${base}`;
704
+ // The member files AS THEY ARE ON THAT BRANCH (review 2, finding 3): the checkout may be behind — still
705
+ // holding a file someone removed, or missing one someone added — and the PR is judged against the branch.
706
+ const existingAll = membersAt(root, baseRef, identity);
707
+ const mine = existingAll.members.find((m) => proofs.some((p) => p.account.platform === identity.platform && p.account.host === identity.host && p.account.login.toLowerCase() === m.primary.login.toLowerCase()));
708
+ const built = buildRecord({ identity, proofs, git, today, existing: mine || null, proved: now.replace(/\.\d+Z$/, 'Z') });
709
+ if (built.problem) return refuse(built.problem, built.hint || (built.login ? `run \`${cli} auth login --hostname ${identity.host}\`, then \`yad member add\`` : null));
710
+ const { record, primary, plainByHash } = built;
711
+ if (!Number.isSafeInteger(primary.id)) return refuse(`the platform did not give the numeric id of ${accountLabel(primary)} — the member-check gate needs it`, 'run `yad member add` again; if it keeps failing, report it with `yad report`');
712
+ // A login renamed away and registered again by someone else is another account: the old file is not theirs.
713
+ if (mine && mine.primary.id !== primary.id) return refuse(`${mine.rel} belongs to another account with the login ${forTerminal(primary.login)} (id ${mine.primary.id}, yours is ${primary.id})`, 'that file is someone else\'s — ask the team to remove it (`yad member remove <login> --reason …`), then run `yad member add`');
714
+ // An account or email another member file already holds is not added here: CI would refuse the PR.
715
+ // A clash that CI refuses: your Product account as someone's primary, or an email another file holds. An
716
+ // OTHER account someone else also claims is not refused — your file is the truth about you, and the
717
+ // claims are reported (`yad member list`, `yad doctor`) until one is dropped.
718
+ const taken = existingAll.members.filter((m) => m !== mine);
719
+ if (taken.some((m) => accountKey(m.primary) === accountKey(primary))) return refuse(`the ${accountLabel(primary)} account is already in another member file`, 'one account belongs to one member — if that file is yours from another login, remove it first (`yad member remove <login> --reason …`)');
720
+ // An email another file lists is left out of yours, with a warning — never a refusal of the whole add
721
+ // (review 3): on GitLab anyone can list a known address, and that must not block its owner from joining.
722
+ const shared = record.emails.filter((h) => taken.some((m) => m.emails.includes(h)));
723
+ if (shared.length) {
724
+ warn(`${shared.length} of your emails ${shared.length === 1 ? 'is' : 'are'} already in another member file — left out of yours; \`yad member list\` names the file`);
725
+ record.emails = record.emails.filter((h) => !shared.includes(h));
726
+ if (!record.emails.length) return refuse('every email of yours is already in another member file', 'ask the team to remove the file that lists them (`yad member remove <login> --reason …`), then run `yad member add`');
727
+ }
728
+ for (const a of record.accounts.slice(1)) {
729
+ const other = taken.find((m) => m.accounts.some((b) => accountKey(b) === accountKey(a)));
730
+ if (other) warn(`the ${accountLabel(a)} account is also claimed by ${other.rel} — neither claim is matched until one file drops it`);
731
+ }
732
+ const rel = memberRel(identity.platform, primary.login);
733
+ if (identity.platform === 'github' && identity.verified && !memberGateLive(root, identity)) {
734
+ warn('the member-check gate is missing or not the shipped copy here — until `yad update` installs it, the active-people count does not use member files, and a file merged before it is not trusted later either (run `yad member add` again then)');
735
+ }
736
+ if (built.dropped) info(`${built.dropped} email(s) in your file are no longer verified on the account — dropped`);
737
+ // Up to date only when nothing but the day changed AND the gate as shipped has judged the file — else a
738
+ // fresh change re-proves every email (review 5).
739
+ const strip = (r) => memberJSON({ accounts: r.accounts, emails: r.emails, names: r.names, joined: r.joined });
740
+ // Not live: re-proving cannot make the file trusted, so a re-run opens nothing (review 6).
741
+ const jf = identity.platform === 'github' && identity.verified ? judgedMemberFiles(root, identity) : null;
742
+ const judged = !jf || !jf.live || jf.files.has(rel);
743
+ const same = !!mine && judged && strip(mine) === strip(record);
744
+ for (const s of built.skipped) info(`${s}: logged in here, but shares no verified email with ${accountLabel(primary)} — not added`);
745
+ if (built.unproven) info(`${built.unproven} git email(s) here are not verified on the account — not added (they stay counted as their own person)`);
746
+ if (same) {
747
+ ok(`${rel} is up to date — ${accountLabel(primary)}`);
748
+ return { login: primary.login, path: rel, changed: false };
749
+ }
750
+ const subject = mine ? `chore(product): update team member ${primary.login}` : `chore(product): add team member ${primary.login}`;
751
+ const made = commitMember(root, { rel, record, plainByHash, base: baseRef, name: git.names[0], login: primary.login, subject });
752
+ if (made.error) return refuse(made.error, 'nothing was pushed; fix it and run `yad member add` again');
753
+ ok(`wrote ${rel} on ${made.branch} (${made.commits.length} commit(s)) — ${accountLabel(primary)}${record.accounts.length > 1 ? ` + ${record.accounts.length - 1} other account(s)` : ''}, ${record.emails.length} email(s), stored hashed`);
754
+ if (noPush) {
755
+ hand(`push it and open a ${identity.platform === 'github' ? 'PR' : 'MR'}: git push -u origin ${made.branch}`);
756
+ return { login: primary.login, path: rel, changed: true, branch: made.branch, pushed: false };
757
+ }
758
+ const pub = publishMember(root, { branch: made.branch, base, login: primary.login, platform: identity.platform, host: identity.host, title: subject, emails: record.emails.length, runner });
759
+ if (pub.error) {
760
+ warn(pub.error);
761
+ hand(pub.pushed ? `open it yourself from ${made.branch} into ${base}` : `when you can push: git push -u origin ${made.branch}, then open a ${identity.platform === 'github' ? 'PR' : 'MR'} into ${base} (or run \`yad member add\` again)`);
762
+ } else ok(`opened ${pub.url || `the ${identity.platform === 'github' ? 'PR' : 'MR'}`} — it joins the team list once it merges`);
763
+ return { login: primary.login, path: rel, changed: true, branch: made.branch, pushed: !!pub.pushed, url: pub.url || null };
764
+ }
765
+
766
+ // `yad member list` — every member, their status and why; every file that cannot be used; every duplicate.
767
+ export async function runMemberList(root, { events = null, today = new Date().toISOString().slice(0, 10), offline = false, access = accessCheck } = {}) {
768
+ const refuse = refuser();
769
+ if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/product.json here)', 'run it from the Product, or pass --dir');
770
+ const productConfig = readJSON(productConfigPath(root), null);
771
+ const identity = productIdentity(root, { productConfig });
772
+ const got = readMembers(root, { identity });
773
+ const ttl = ttlDays(productConfig);
774
+ let evidence = events;
775
+ if (!evidence) {
776
+ const { peopleEvidence } = await import('./people.mjs');
777
+ try { evidence = peopleEvidence(root, { today, sinceDays: Math.max(ttl, 1) + 1 }).events; } catch { evidence = []; }
778
+ }
779
+ const members = memberStatuses(root, got.members, { events: evidence, today, ttl, identity, access, offline });
780
+ const team = members.filter((m) => m.status !== 'left');
781
+ if (!members.length && !got.errors.length) info('no member files yet — each person runs `yad member add` (or `yad join` does it)');
782
+ for (const m of members) {
783
+ const others = m.accounts.length > 1 ? ` + ${m.accounts.slice(1).map(accountLabel).join(', ')}` : '';
784
+ const why = m.status === 'active' ? `last active ${m.lastActive}` : m.status === 'idle' ? `nothing in ${ttl} days${m.lastActive ? ` (last ${m.lastActive})` : ''}, still has access`
785
+ : m.status === 'left' ? 'no longer has access to the Product — off the team list until they commit or approve again' : `nothing in ${ttl} days; access could not be checked`;
786
+ (m.status === 'left' ? warn : info)(`${accountLabel(m.primary)}${others} — ${m.status}: ${why}`);
787
+ }
788
+ for (const e of got.errors) warn(e.error);
789
+ for (const d of got.duplicates) warn(`${d} — one account or email belongs to one member; neither file joins it until one is fixed`);
790
+ if (identity.platform === 'gitlab' && members.length) info('on GitLab the pairings feed the team list only, not the gate count (CI cannot prove an email belongs to the MR author)');
791
+ if (!identity.verified && members.length) info('no CI checks member files on this Product (a local ledger, or no platform) — each file is its owner\'s own statement');
792
+ return {
793
+ ttlDays: ttl,
794
+ members: members.map((m) => ({ path: m.rel, login: m.primary.login, accounts: m.accounts, names: m.names, emails: m.emails.length, joined: m.joined, status: m.status, lastActive: m.lastActive || null })),
795
+ team: team.map((m) => m.primary.login),
796
+ errors: got.errors.map((e) => e.error),
797
+ duplicates: got.duplicates,
798
+ };
799
+ }
800
+
801
+ // `yad member remove [<login>] [--reason]` — delete a member file. Your own needs no reason; anyone
802
+ // else's needs one, and is a deletion only (the CI check lets a PR that only DELETES a file through).
803
+ export async function runMemberRemove(root, { login = null, reason = null, runner = run, env = process.env } = {}) {
804
+ const refuse = refuser();
805
+ if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/product.json here)', 'run it from the Product, or pass --dir');
806
+ const identity = productIdentity(root, { runner });
807
+ if (!identity.platform) return refuse('the Product names no platform, so it has no member files');
808
+ const me = identity.platform === 'github'
809
+ ? ((ghAccounts({ runner, env }).filter((a) => a.host === identity.host).find((a) => a.active) || {}).login || null)
810
+ : (() => { const r = runner('glab', ['api', '--hostname', identity.host, 'user'], { env, timeout: TIMEOUT }); const u = r.ok ? parse(r.stdout) : null; return validLogin(u?.username) ? u.username : null; })();
811
+ const who = login || me;
812
+ if (!who) return refuse('whose member file? (not logged in here, so yad cannot tell who you are)', 'usage: yad member remove [<login>] [--reason "<why>"]');
813
+ if (!validLogin(who)) return refuse(`not a login: ${forTerminal(who)}`);
814
+ const rel = memberRel(identity.platform, who);
815
+ if (!fs.existsSync(path.join(root, rel))) return refuse(`no member file for ${forTerminal(who)} (${rel})`, '`yad member list` shows the members');
816
+ const own = me && me.toLowerCase() === who.toLowerCase();
817
+ if (!own && !(typeof reason === 'string' && reason.trim())) return refuse(`${who} is not you — removing someone else's member file needs --reason "<why>"`, `yad member remove ${who} --reason "<why>"`);
818
+ fs.rmSync(path.join(root, rel), { force: true });
819
+ ok(`removed ${rel}${own ? '' : ` — reason: ${forTerminal(reason.trim())}`}`);
820
+ hand(`commit the removal on its own${own ? '' : `, with the reason in the message: git commit -m "chore(product): remove team member ${who}" -m "Reason: ${forTerminal(reason.trim()).replace(/"/g, "'")}"`} — on a verified Product, in a PR of its own`);
821
+ return { login: who, path: rel, removed: true, reason: own ? null : reason.trim() };
822
+ }