@reneturcios/greenlight 0.10.1 → 0.12.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.
@@ -0,0 +1,996 @@
1
+ /**
2
+ * Greenlight Claim Protocol hosted-API client (v0.12.0).
3
+ *
4
+ * This module implements `greenlight auth login`, `greenlight claim
5
+ * --via-api`, `greenlight release --via-api`, `greenlight heartbeat
6
+ * --via-api`, and the MCP tools api_status / claim_via_api /
7
+ * heartbeat_via_api. It speaks to the claim-api service (default
8
+ * https://claim-api-wine.vercel.app, the existing hosted instance) and
9
+ * adds the app-aware path selection: before any --via-api write, the
10
+ * client asks the backend whether the Greenlight Claims app is installed
11
+ * on the target repo. Installed -> the claim goes through the app backend
12
+ * (POST /v1/claims; the server posts the protocol comment as the caller
13
+ * and the webhook records durable state). Not installed, or the
14
+ * installation endpoint missing (backend older than v1.0.0), or the API
15
+ * unreachable on that route -> the client falls back to the current
16
+ * direct-API comment path and prints a one-line note naming that the app
17
+ * is not installed. The downgrade is never silent.
18
+ *
19
+ * Auth: `greenlight auth login` runs GitHub's OAuth device flow against
20
+ * the claim-api OAuth app's client id and stores the resulting GitHub
21
+ * user token (public_repo scope). `auth login --browser` walks the
22
+ * browser OAuth grant and stores the pasted session token. Option 3 is
23
+ * passthrough: --token or GREENLIGHT_API_TOKEN.
24
+ *
25
+ * Token storage: OS keychain first (macOS `security`, Linux
26
+ * `secret-tool`); when neither is available, ~/.greenlight/api-token
27
+ * with 0600 permissions. The store records the token kind (a raw GitHub
28
+ * token from the device flow or passthrough, or a claim-api session
29
+ * token from the browser flow) so the direct-comment fallback knows
30
+ * whether it can post as the caller without another env var.
31
+ */
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
35
+ import { execFile, spawn } from "node:child_process";
36
+ import { promisify } from "node:util";
37
+ import { createInterface } from "node:readline";
38
+ export const DEFAULT_API_BASE = "https://claim-api-wine.vercel.app";
39
+ /**
40
+ * The claim-api OAuth app client id. Read from the live instance's
41
+ * /auth/github/login redirect (2026-10-18); override with
42
+ * GREENLIGHT_OAUTH_CLIENT_ID or --client-id when the app rotates.
43
+ */
44
+ export const DEFAULT_OAUTH_CLIENT_ID = "Ov23liv9yM4RTci0UysE";
45
+ export const GITHUB_DEVICE_BASE = "https://github.com";
46
+ export const GITHUB_API_BASE = "https://api.github.com";
47
+ /** Claim Protocol marker; the fallback comment is byte-compatible with it. */
48
+ export const CLAIM_MARKER_V1 = "<!-- greenlight-claim v1 -->";
49
+ export function configFilePath(home = homedir()) {
50
+ return join(home, ".config", "greenlight", "config.json");
51
+ }
52
+ export function readApiConfig(home = homedir()) {
53
+ try {
54
+ const raw = readFileSync(configFilePath(home), "utf8");
55
+ const parsed = JSON.parse(raw);
56
+ if (typeof parsed.apiBase === "string" && parsed.apiBase.trim().length > 0) {
57
+ return { apiBase: parsed.apiBase.trim() };
58
+ }
59
+ return null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ export function writeApiConfig(apiBase, home = homedir()) {
66
+ const path = configFilePath(home);
67
+ mkdirSync(join(home, ".config", "greenlight"), { recursive: true });
68
+ writeFileSync(path, JSON.stringify({ apiBase }, null, 2) + "\n", "utf8");
69
+ }
70
+ /**
71
+ * Resolution order: --api-base flag, GREENLIGHT_API_BASE env, the cached
72
+ * config file, then the hosted default. A successful --api-base or env
73
+ * value is cached so later calls need no flag.
74
+ */
75
+ export function resolveApiBase(opts) {
76
+ const env = opts.env ?? process.env;
77
+ const home = opts.home ?? homedir();
78
+ const flag = opts.flag?.trim();
79
+ if (flag) {
80
+ writeApiConfig(flag, home);
81
+ return flag;
82
+ }
83
+ const fromEnv = (env.GREENLIGHT_API_BASE ?? "").trim();
84
+ if (fromEnv) {
85
+ writeApiConfig(fromEnv, home);
86
+ return fromEnv;
87
+ }
88
+ return readApiConfig(home)?.apiBase ?? DEFAULT_API_BASE;
89
+ }
90
+ const KEYCHAIN_SERVICE = "greenlight";
91
+ const KEYCHAIN_ACCOUNT = "claim-api-token";
92
+ const execFileAsync = promisify(execFile);
93
+ async function commandExists(cmd) {
94
+ try {
95
+ await execFileAsync(process.platform === "win32" ? "where" : "which", [cmd]);
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /** macOS `security` CLI keychain store. */
103
+ class MacKeychainStore {
104
+ async get() {
105
+ try {
106
+ const { stdout } = await execFileAsync("security", [
107
+ "find-generic-password",
108
+ "-a",
109
+ KEYCHAIN_ACCOUNT,
110
+ "-s",
111
+ KEYCHAIN_SERVICE,
112
+ "-w",
113
+ ]);
114
+ return parseStoredToken(stdout.trim());
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ async set(t) {
121
+ await this.clear().catch(() => undefined);
122
+ await execFileAsync("security", [
123
+ "add-generic-password",
124
+ "-a",
125
+ KEYCHAIN_ACCOUNT,
126
+ "-s",
127
+ KEYCHAIN_SERVICE,
128
+ "-w",
129
+ serializeStoredToken(t),
130
+ ]);
131
+ }
132
+ async clear() {
133
+ await execFileAsync("security", [
134
+ "delete-generic-password",
135
+ "-a",
136
+ KEYCHAIN_ACCOUNT,
137
+ "-s",
138
+ KEYCHAIN_SERVICE,
139
+ ]);
140
+ }
141
+ describe() {
142
+ return "macOS keychain (security)";
143
+ }
144
+ }
145
+ /** Linux libsecret `secret-tool` store. */
146
+ class SecretToolStore {
147
+ async get() {
148
+ try {
149
+ const { stdout } = await execFileAsync("secret-tool", [
150
+ "lookup",
151
+ "service",
152
+ KEYCHAIN_SERVICE,
153
+ "account",
154
+ KEYCHAIN_ACCOUNT,
155
+ ]);
156
+ return parseStoredToken(stdout.trim());
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ async set(t) {
163
+ await new Promise((resolve, reject) => {
164
+ const child = spawn("secret-tool", [
165
+ "store",
166
+ "--label=greenlight claim-api token",
167
+ "service",
168
+ KEYCHAIN_SERVICE,
169
+ "account",
170
+ KEYCHAIN_ACCOUNT,
171
+ ]);
172
+ child.on("error", reject);
173
+ child.stdin.write(serializeStoredToken(t));
174
+ child.stdin.end();
175
+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`secret-tool exited ${code}`))));
176
+ });
177
+ }
178
+ async clear() {
179
+ await execFileAsync("secret-tool", ["clear", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT]);
180
+ }
181
+ describe() {
182
+ return "libsecret keychain (secret-tool)";
183
+ }
184
+ }
185
+ /** File fallback: ~/.greenlight/api-token, always 0600. */
186
+ export class FileTokenStore {
187
+ home;
188
+ constructor(home = homedir()) {
189
+ this.home = home;
190
+ }
191
+ path() {
192
+ return join(this.home, ".greenlight", "api-token");
193
+ }
194
+ async get() {
195
+ try {
196
+ return parseStoredToken(readFileSync(this.path(), "utf8").trim());
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ async set(t) {
203
+ mkdirSync(join(this.home, ".greenlight"), { recursive: true });
204
+ writeFileSync(this.path(), serializeStoredToken(t), { mode: 0o600 });
205
+ chmodSync(this.path(), 0o600);
206
+ }
207
+ async clear() {
208
+ try {
209
+ unlinkSync(this.path());
210
+ }
211
+ catch {
212
+ /* already gone */
213
+ }
214
+ }
215
+ describe() {
216
+ return `file fallback ${this.path()} (0600; no OS keychain found)`;
217
+ }
218
+ }
219
+ function serializeStoredToken(t) {
220
+ return JSON.stringify({ v: 1, token: t.token, kind: t.kind, login: t.login, storedAt: t.storedAt });
221
+ }
222
+ function parseStoredToken(raw) {
223
+ if (!raw)
224
+ return null;
225
+ try {
226
+ const p = JSON.parse(raw);
227
+ if (typeof p.token !== "string" || p.token.length === 0)
228
+ return null;
229
+ const kind = p.kind === "session" ? "session" : "github";
230
+ return {
231
+ token: p.token,
232
+ kind,
233
+ login: typeof p.login === "string" ? p.login : null,
234
+ storedAt: typeof p.storedAt === "string" ? p.storedAt : new Date().toISOString(),
235
+ };
236
+ }
237
+ catch {
238
+ // Legacy: a raw token string with no envelope; treat as a GitHub token.
239
+ return { token: raw, kind: "github", login: null, storedAt: new Date().toISOString() };
240
+ }
241
+ }
242
+ /**
243
+ * The default store: OS keychain when a keychain tool is available,
244
+ * otherwise the 0600 file fallback. Callers (and tests) may inject their
245
+ * own TokenStore instead.
246
+ */
247
+ export async function defaultTokenStore(home = homedir()) {
248
+ if (process.platform === "darwin" && (await commandExists("security"))) {
249
+ return new MacKeychainStore();
250
+ }
251
+ if (process.platform === "linux" && (await commandExists("secret-tool"))) {
252
+ return new SecretToolStore();
253
+ }
254
+ return new FileTokenStore(home);
255
+ }
256
+ export async function resolveApiToken(opts) {
257
+ const env = opts.env ?? process.env;
258
+ const flag = (opts.flag ?? "").trim();
259
+ if (flag)
260
+ return { token: flag, kind: "github", login: null, source: "flag" };
261
+ const fromEnv = (env.GREENLIGHT_API_TOKEN ?? "").trim();
262
+ if (fromEnv)
263
+ return { token: fromEnv, kind: "github", login: null, source: "env" };
264
+ const store = opts.store ?? (await defaultTokenStore(opts.home ?? homedir()));
265
+ const stored = await store.get();
266
+ if (!stored)
267
+ return null;
268
+ return { token: stored.token, kind: stored.kind, login: stored.login, source: "store" };
269
+ }
270
+ export function noTokenMessage() {
271
+ return [
272
+ "No claim-api token found.",
273
+ "Run `greenlight auth login` to sign in with the GitHub device flow,",
274
+ "or pass --token <github-token>, or set GREENLIGHT_API_TOKEN.",
275
+ ].join("\n");
276
+ }
277
+ async function postJson(fetchImpl, url, body) {
278
+ const res = await fetchImpl(url, {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "greenlight" },
281
+ body: JSON.stringify(body),
282
+ });
283
+ let data = null;
284
+ try {
285
+ data = await res.json();
286
+ }
287
+ catch {
288
+ /* non-JSON body */
289
+ }
290
+ return { status: res.status, data };
291
+ }
292
+ /**
293
+ * Run the GitHub OAuth device flow, store the resulting user token, and
294
+ * return it. Polling honors the server's interval; mocked-fetch tests
295
+ * inject a tiny interval.
296
+ */
297
+ export async function deviceFlowLogin(opts = {}) {
298
+ const fetchImpl = opts.fetchImpl ?? fetch;
299
+ const clientId = opts.clientId ?? process.env.GREENLIGHT_OAUTH_CLIENT_ID ?? DEFAULT_OAUTH_CLIENT_ID;
300
+ const deviceBase = opts.deviceBase ?? GITHUB_DEVICE_BASE;
301
+ const apiBase = opts.apiBase ?? GITHUB_API_BASE;
302
+ const onCode = opts.onCode ??
303
+ ((userCode, verificationUri) => {
304
+ console.log(`Enter code ${userCode} at ${verificationUri}`);
305
+ console.log("Waiting for authorization...");
306
+ });
307
+ const start = await postJson(fetchImpl, `${deviceBase}/login/device/code`, {
308
+ client_id: clientId,
309
+ scope: "public_repo",
310
+ });
311
+ if (start.status !== 200 || !start.data?.device_code) {
312
+ throw new Error(`Device flow failed to start (HTTP ${start.status}): ${start.data?.error_description ?? start.data?.error ?? "no device code"}.`);
313
+ }
314
+ const dc = start.data;
315
+ onCode(dc.user_code, dc.verification_uri);
316
+ // GitHub requires at least a 5-second poll interval in production;
317
+ // tests inject a smaller one explicitly.
318
+ const intervalMs = opts.pollIntervalMs ?? Math.max(dc.interval * 1000, 5000);
319
+ const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60 * 1000);
320
+ let token;
321
+ for (;;) {
322
+ if (Date.now() > deadline) {
323
+ throw new Error("Device flow timed out waiting for authorization (10 minutes). Re-run greenlight auth login.");
324
+ }
325
+ await new Promise((r) => setTimeout(r, intervalMs));
326
+ const poll = await postJson(fetchImpl, `${deviceBase}/login/oauth/access_token`, {
327
+ client_id: clientId,
328
+ device_code: dc.device_code,
329
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
330
+ });
331
+ const err = poll.data?.error;
332
+ if (typeof poll.data?.access_token === "string") {
333
+ token = poll.data.access_token;
334
+ break;
335
+ }
336
+ if (err === "authorization_pending" || err === "slow_down")
337
+ continue;
338
+ if (err === "expired_token") {
339
+ throw new Error("The device code expired before you authorized it. Re-run greenlight auth login.");
340
+ }
341
+ if (err === "access_denied") {
342
+ throw new Error("Authorization was denied in the browser. Re-run greenlight auth login to try again.");
343
+ }
344
+ throw new Error(`Device flow polling failed: ${err ?? poll.data?.error_description ?? `HTTP ${poll.status}`}.`);
345
+ }
346
+ return finalizeDeviceToken(fetchImpl, apiBase, token ?? "", opts);
347
+ }
348
+ /** Verify the device-flow token against /user, store it, and return it. */
349
+ async function finalizeDeviceToken(fetchImpl, apiBase, token, opts) {
350
+ if (!token)
351
+ throw new Error("Device flow returned no access token.");
352
+ let login = null;
353
+ try {
354
+ const res = await fetchImpl(`${apiBase}/user`, {
355
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "User-Agent": "greenlight" },
356
+ });
357
+ if (res.ok) {
358
+ const u = (await res.json());
359
+ login = typeof u.login === "string" ? u.login : null;
360
+ }
361
+ }
362
+ catch {
363
+ /* keep login null; the token still works on write endpoints */
364
+ }
365
+ const stored = { token, kind: "github", login, storedAt: new Date().toISOString() };
366
+ const store = opts.store ?? (await defaultTokenStore());
367
+ await store.set(stored);
368
+ return { token, kind: "github", login, source: "store" };
369
+ }
370
+ async function defaultOpenUrl(url) {
371
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
372
+ try {
373
+ await execFileAsync(cmd, [url]);
374
+ }
375
+ catch {
376
+ /* the printed URL is the fallback */
377
+ }
378
+ }
379
+ async function defaultPromptToken() {
380
+ const iface = createInterface({ input: process.stdin, output: process.stdout });
381
+ try {
382
+ return await new Promise((resolve) => {
383
+ iface.question("Paste the session token from the callback page: ", (answer) => {
384
+ resolve(answer.trim() || null);
385
+ });
386
+ });
387
+ }
388
+ finally {
389
+ iface.close();
390
+ }
391
+ }
392
+ /**
393
+ * Browser OAuth grant: open the claim-api login page, the user completes
394
+ * the grant in the browser, and pastes the session token from the
395
+ * ?format=json callback page. Stored as kind "session".
396
+ */
397
+ export async function browserLoginFlow(opts = {}) {
398
+ const apiBase = opts.apiBase ?? resolveApiBase({ env: opts.env, home: opts.home });
399
+ const loginUrl = `${apiBase}/auth/github/login`;
400
+ console.log(`Opening ${loginUrl} ...`);
401
+ await (opts.openUrl ?? defaultOpenUrl)(loginUrl);
402
+ console.log("Complete the grant in your browser, then paste the session token shown on the callback page.");
403
+ console.log("Tip: add ?format=json to the callback URL to get the token as JSON.");
404
+ const token = (await (opts.promptToken ?? defaultPromptToken)())?.trim() ?? null;
405
+ if (!token)
406
+ throw new Error("No token pasted; login aborted.");
407
+ const stored = { token, kind: "session", login: null, storedAt: new Date().toISOString() };
408
+ const store = opts.store ?? (await defaultTokenStore(opts.home ?? homedir()));
409
+ await store.set(stored);
410
+ return { token, kind: "session", login: null, source: "store" };
411
+ }
412
+ // ---------------------------------------------------------------------------
413
+ // claim-api HTTP client
414
+ // ---------------------------------------------------------------------------
415
+ export class ApiError extends Error {
416
+ status;
417
+ code;
418
+ payload;
419
+ constructor(status, code, message, payload) {
420
+ super(message);
421
+ this.name = "ApiError";
422
+ this.status = status;
423
+ this.code = code;
424
+ this.payload = payload;
425
+ }
426
+ }
427
+ export class ClaimApiClient {
428
+ base;
429
+ token;
430
+ fetchImpl;
431
+ constructor(opts) {
432
+ this.base = opts.base.replace(/\/+$/, "");
433
+ this.token = opts.token;
434
+ this.fetchImpl = opts.fetchImpl ?? fetch;
435
+ }
436
+ async request(method, path, body) {
437
+ const headers = {
438
+ Accept: "application/json",
439
+ "User-Agent": "greenlight",
440
+ };
441
+ if (this.token)
442
+ headers.Authorization = `Bearer ${this.token}`;
443
+ let bodyText;
444
+ if (body !== undefined) {
445
+ headers["Content-Type"] = "application/json";
446
+ bodyText = JSON.stringify(body);
447
+ }
448
+ let res;
449
+ try {
450
+ res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: bodyText });
451
+ }
452
+ catch (err) {
453
+ throw new ApiError(0, "connection-failed", `Could not reach the claim API at ${this.base}: ${err instanceof Error ? err.message : String(err)}.`);
454
+ }
455
+ let data = null;
456
+ try {
457
+ data = await res.json();
458
+ }
459
+ catch {
460
+ /* non-JSON body */
461
+ }
462
+ if (!res.ok) {
463
+ const code = typeof data?.error === "string" ? data.error : undefined;
464
+ const message = typeof data?.message === "string" ? data.message : `HTTP ${res.status}`;
465
+ throw new ApiError(res.status, code, message, data);
466
+ }
467
+ return { status: res.status, data };
468
+ }
469
+ /** Public status payload (SPEC 5.1 shape plus competing PRs/gates). */
470
+ async issueStatus(owner, repo, number) {
471
+ const { data } = await this.request("GET", `/v1/issues/${owner}/${repo}/${number}/status`);
472
+ return data;
473
+ }
474
+ /**
475
+ * App-aware check: is the Greenlight Claims app installed on this repo?
476
+ * Returns null when the endpoint does not exist (backend older than
477
+ * v1.0.0 returns 404) or the route cannot be reached; the caller treats
478
+ * null as "not installed" and falls back to the direct comment path.
479
+ */
480
+ async appInstallation(owner, repo) {
481
+ try {
482
+ const { data } = await this.request("GET", `/v1/app/repos/${owner}/${repo}/installation`);
483
+ return { installed: data?.installed === true, app_slug: typeof data?.app_slug === "string" ? data.app_slug : undefined };
484
+ }
485
+ catch (err) {
486
+ if (err instanceof ApiError && (err.status === 404 || err.status === 0))
487
+ return null;
488
+ throw err;
489
+ }
490
+ }
491
+ async postClaim(owner, repo, number, opts) {
492
+ const { data } = await this.request("POST", "/v1/claims", {
493
+ owner,
494
+ repo,
495
+ issue: number,
496
+ ...(opts.plan !== undefined ? { plan: opts.plan } : {}),
497
+ ...(opts.eta_days !== undefined ? { eta_days: opts.eta_days } : {}),
498
+ ...(opts.queue_ok !== undefined ? { queue_ok: opts.queue_ok } : {}),
499
+ });
500
+ return data;
501
+ }
502
+ async postRelease(owner, repo, number) {
503
+ const { data } = await this.request("POST", "/v1/claims/release", { owner, repo, issue: number });
504
+ return data;
505
+ }
506
+ async postHeartbeat(owner, repo, number, mode) {
507
+ const { data } = await this.request("POST", "/v1/claims/heartbeat", {
508
+ owner,
509
+ repo,
510
+ issue: number,
511
+ ...(mode ? { mode } : {}),
512
+ });
513
+ return data;
514
+ }
515
+ }
516
+ /**
517
+ * Before any --via-api write: ask the backend whether the Greenlight
518
+ * Claims app is installed on the repo. Installed -> proceed through the
519
+ * app backend. Not installed, or the endpoint missing/unreachable ->
520
+ * fall back to the current direct-API comment path with the printed
521
+ * note. Never silently downgrade.
522
+ */
523
+ export async function chooseViaApiPath(client, owner, repo) {
524
+ const installation = await client.appInstallation(owner, repo);
525
+ if (installation?.installed) {
526
+ return { path: "app", note: null };
527
+ }
528
+ return {
529
+ path: "direct",
530
+ note: `Note: the Greenlight Claims app is not installed on ${owner}/${repo}, so this claim posts as a plain protocol comment.`,
531
+ };
532
+ }
533
+ // ---------------------------------------------------------------------------
534
+ // Status-chain confirmation
535
+ // ---------------------------------------------------------------------------
536
+ /**
537
+ * After a via-api write, re-read the status payload and check the
538
+ * caller's login appears in the active claim or the queue. Returns false
539
+ * when it cannot be confirmed (the CLI then prints the comment URL and
540
+ * tells the user to check, rather than claiming success).
541
+ */
542
+ export function statusConfirmsLogin(status, login) {
543
+ if (!login)
544
+ return false;
545
+ const active = status?.active ?? status?.claim;
546
+ if (active && typeof active.claimant === "string" && active.claimant.toLowerCase() === login.toLowerCase()) {
547
+ return true;
548
+ }
549
+ const queue = status?.queue ?? active?.queue ?? [];
550
+ if (Array.isArray(queue)) {
551
+ return queue.some((q) => typeof q === "object" && q !== null && String(q.claimant ?? "").toLowerCase() === login.toLowerCase());
552
+ }
553
+ return false;
554
+ }
555
+ const TAKEN_HOLDER_LINE = (holder) => `Held by @${holder.claimant}${holder.expiresAt ? ` until ${holder.expiresAt.slice(0, 10)}` : ""}.`;
556
+ function holderFromStatus(status) {
557
+ const active = status?.active ?? status?.claim;
558
+ if (active && typeof active.claimant === "string") {
559
+ return {
560
+ claimant: active.claimant,
561
+ expiresAt: typeof active.expires_at === "string" ? active.expires_at : null,
562
+ };
563
+ }
564
+ return null;
565
+ }
566
+ function holderFrom409(payload) {
567
+ const p = payload;
568
+ if (p?.holder && typeof p.holder.claimant === "string") {
569
+ return {
570
+ claimant: p.holder.claimant,
571
+ expiresAt: typeof p.holder.expires_at === "string" ? p.holder.expires_at : null,
572
+ };
573
+ }
574
+ return null;
575
+ }
576
+ async function githubLoginFor(fetchImpl, token) {
577
+ try {
578
+ const res = await fetchImpl(`${GITHUB_API_BASE}/user`, {
579
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "User-Agent": "greenlight" },
580
+ });
581
+ if (!res.ok)
582
+ return null;
583
+ const u = (await res.json());
584
+ return typeof u.login === "string" ? u.login : null;
585
+ }
586
+ catch {
587
+ return null;
588
+ }
589
+ }
590
+ /** Resolve the base + bearer and learn the caller's login when possible. */
591
+ async function resolveCall(opts, deps) {
592
+ const base = resolveApiBase({ flag: opts.apiBaseFlag, env: deps.env, home: deps.home });
593
+ const token = await resolveApiToken({ flag: opts.tokenFlag, env: deps.env, store: deps.store, home: deps.home });
594
+ if (!token)
595
+ return { outcome: "no-token", message: noTokenMessage() };
596
+ let login = token.login;
597
+ if (!login && token.kind === "github") {
598
+ login = await githubLoginFor(deps.fetchImpl ?? fetch, token.token);
599
+ }
600
+ const client = new ClaimApiClient({ base, token: token.token, fetchImpl: deps.fetchImpl });
601
+ return { base, client, token, login };
602
+ }
603
+ /** Run one API write with a single re-auth retry on 401 (device flow once). */
604
+ async function withReauth(call, deps, write) {
605
+ try {
606
+ return { ok: true, value: await write(call.client) };
607
+ }
608
+ catch (err) {
609
+ if (!(err instanceof ApiError) || err.status !== 401)
610
+ throw err;
611
+ }
612
+ (deps.print ?? console.log)("Token rejected (401). Re-running the device login once...");
613
+ let relogged;
614
+ try {
615
+ relogged = deps.relogin
616
+ ? await deps.relogin()
617
+ : await deviceFlowLogin({ fetchImpl: deps.fetchImpl, store: deps.store });
618
+ }
619
+ catch (reloginErr) {
620
+ return {
621
+ ok: false,
622
+ message: `Re-login failed: ${reloginErr instanceof Error ? reloginErr.message : String(reloginErr)}.`,
623
+ };
624
+ }
625
+ const retryClient = new ClaimApiClient({ base: call.base, token: relogged.token, fetchImpl: deps.fetchImpl });
626
+ try {
627
+ return { ok: true, value: await write(retryClient) };
628
+ }
629
+ catch (err2) {
630
+ if (err2 instanceof ApiError && err2.status === 401) {
631
+ return {
632
+ ok: false,
633
+ message: [
634
+ "The new token was also rejected (401).",
635
+ "Manual steps: run `greenlight auth login` again, check the token has not been revoked,",
636
+ "or pass --token with a fresh GitHub token (or set GREENLIGHT_API_TOKEN).",
637
+ ].join("\n"),
638
+ };
639
+ }
640
+ throw err2;
641
+ }
642
+ }
643
+ /** The GitHub token usable for the direct-comment fallback, if any. */
644
+ async function githubTokenForDirect(call, deps) {
645
+ if (call.token.kind === "github")
646
+ return call.token.token;
647
+ const env = deps.env ?? process.env;
648
+ const fromEnv = (env.GITHUB_TOKEN ?? env.GH_TOKEN ?? "").trim();
649
+ return fromEnv || null;
650
+ }
651
+ export async function viaApiClaim(issue, opts, deps = {}) {
652
+ const print = deps.print ?? console.log;
653
+ const base = resolveApiBase({ flag: opts.apiBaseFlag, env: deps.env, home: deps.home });
654
+ // Dry run needs no token: the plan comes from public reads (status +
655
+ // installation check) and the local comment builder.
656
+ if (!opts.yes) {
657
+ const client = new ClaimApiClient({ base, fetchImpl: deps.fetchImpl });
658
+ let status;
659
+ try {
660
+ status = await client.issueStatus(issue.owner, issue.repo, issue.number);
661
+ }
662
+ catch (err) {
663
+ if (err instanceof ApiError && err.status === 0) {
664
+ return {
665
+ outcome: "api-unreachable",
666
+ path: null,
667
+ note: null,
668
+ message: `The claim API is unreachable at ${base}. You can still claim by hand: greenlight claim ${issue.url}`,
669
+ };
670
+ }
671
+ throw err;
672
+ }
673
+ const { path, note } = await chooseViaApiPath(client, issue.owner, issue.repo);
674
+ if (note)
675
+ print(note);
676
+ const { buildClaimComment } = await import("./automate.js");
677
+ const commentBody = buildClaimComment({ title: status?.title ?? status?.issueTitle ?? "" }, [], undefined, { plan: opts.plan, etaDays: opts.etaDays });
678
+ return {
679
+ outcome: "dry-run",
680
+ path,
681
+ note,
682
+ commentBody,
683
+ message: `Dry run: claim comment NOT posted (path: ${path}). Re-run with --yes to post it.`,
684
+ };
685
+ }
686
+ const resolved = await resolveCall(opts, deps);
687
+ if ("outcome" in resolved)
688
+ return { outcome: "no-token", path: null, note: null, message: resolved.message };
689
+ const { client, login } = resolved;
690
+ // Pre-flight: the hosted status read (public; no token needed).
691
+ let status;
692
+ try {
693
+ status = await client.issueStatus(issue.owner, issue.repo, issue.number);
694
+ }
695
+ catch (err) {
696
+ if (err instanceof ApiError && err.status === 0) {
697
+ return {
698
+ outcome: "api-unreachable",
699
+ path: null,
700
+ note: null,
701
+ message: `The claim API is unreachable at ${base}. You can still claim by hand: greenlight claim ${issue.url}`,
702
+ };
703
+ }
704
+ throw err;
705
+ }
706
+ // TAKEN pre-flight (only when the caller's login is known): refuse
707
+ // exactly like the local refusal, unless --queue-ok joins the queue.
708
+ const holder = holderFromStatus(status);
709
+ if (holder && login && holder.claimant.toLowerCase() !== login.toLowerCase() && !opts.queueOk) {
710
+ return {
711
+ outcome: "refused-taken",
712
+ path: null,
713
+ note: null,
714
+ holder,
715
+ login,
716
+ message: `Refusing: ${issue.owner}/${issue.repo}#${issue.number} is actively claimed. ${TAKEN_HOLDER_LINE(holder)} Re-run with --queue-ok to join the public queue.`,
717
+ };
718
+ }
719
+ const { path, note } = await chooseViaApiPath(client, issue.owner, issue.repo);
720
+ if (note)
721
+ print(note);
722
+ if (path === "app") {
723
+ let write;
724
+ try {
725
+ write = await withReauth(resolved, deps, (c) => c.postClaim(issue.owner, issue.repo, issue.number, {
726
+ plan: opts.plan,
727
+ eta_days: opts.etaDays,
728
+ queue_ok: opts.queueOk,
729
+ }));
730
+ }
731
+ catch (err) {
732
+ // The server enforces one active claim per issue: a 409 claim-held
733
+ // renders the same refusal as the local pre-flight (this is the
734
+ // path session-token callers take, since their login is unknown).
735
+ if (err instanceof ApiError && err.status === 409 && err.code === "claim-held") {
736
+ const h = holderFrom409(err.payload) ?? { claimant: "another contributor", expiresAt: null };
737
+ return {
738
+ outcome: "refused-taken",
739
+ path,
740
+ note,
741
+ holder: h,
742
+ login,
743
+ message: `Refusing: ${issue.owner}/${issue.repo}#${issue.number} is actively claimed. ${TAKEN_HOLDER_LINE(h)} Re-run with --queue-ok to join the public queue.`,
744
+ };
745
+ }
746
+ throw err;
747
+ }
748
+ if (!write.ok)
749
+ return { outcome: "auth-failed", path, note, login, message: write.message };
750
+ const data = write.value;
751
+ if (data.action === "already-claimed") {
752
+ return { outcome: "already-claimed", path, note, login, message: "You already hold the active claim on this issue." };
753
+ }
754
+ // Verify the posted comment by re-reading the status and confirming
755
+ // the caller's login appears in the chain; never claim success blind.
756
+ let confirmed = false;
757
+ try {
758
+ const reread = await client.issueStatus(issue.owner, issue.repo, issue.number);
759
+ confirmed = statusConfirmsLogin(reread, login);
760
+ }
761
+ catch {
762
+ /* confirmation is best-effort */
763
+ }
764
+ const queued = (data.queue_position ?? 1) > 1;
765
+ const tail = confirmed
766
+ ? ""
767
+ : ` (posted, but the follow-up status read did not confirm it; check ${data.comment_url ?? "the issue thread"})`;
768
+ return {
769
+ outcome: queued ? "queued" : "claimed",
770
+ path,
771
+ note,
772
+ commentUrl: data.comment_url,
773
+ queuePosition: data.queue_position,
774
+ login,
775
+ message: queued
776
+ ? `Claim queued at position ${data.queue_position}: ${data.comment_url}${tail}`
777
+ : `Claim posted: ${data.comment_url}${tail}`,
778
+ };
779
+ }
780
+ // Direct path: post the protocol comment via the GitHub API, exactly as
781
+ // `greenlight claim --yes` does, with the app's plan/eta fields.
782
+ const githubToken = await githubTokenForDirect(resolved, deps);
783
+ if (!githubToken) {
784
+ return {
785
+ outcome: "direct-fallback",
786
+ path,
787
+ note,
788
+ login,
789
+ message: "The app is not installed, so the claim would post as a plain protocol comment, but no GitHub token is available for the direct path. Set GITHUB_TOKEN, or run greenlight auth login (device flow) for a GitHub token.",
790
+ };
791
+ }
792
+ const executor = deps.directClaim ??
793
+ (async (a) => {
794
+ const [{ createRestClient }, { buildIssueSnapshot }, { claimIssue }] = await Promise.all([
795
+ import("./automate.js"),
796
+ import("./github.js"),
797
+ import("./automate.js"),
798
+ ]);
799
+ const prev = process.env.GITHUB_TOKEN;
800
+ if (!prev)
801
+ process.env.GITHUB_TOKEN = a.githubToken;
802
+ try {
803
+ const snapshot = await buildIssueSnapshot(a.owner, a.repo, a.number);
804
+ const plan = await claimIssue(createRestClient(), snapshot, {
805
+ yes: true,
806
+ force: false,
807
+ plan: a.plan,
808
+ etaDays: a.etaDays,
809
+ });
810
+ return { posted: plan.executed, commentUrl: plan.commentUrl ?? undefined, commentBody: plan.commentBody };
811
+ }
812
+ finally {
813
+ if (!prev)
814
+ delete process.env.GITHUB_TOKEN;
815
+ }
816
+ });
817
+ const direct = await executor({
818
+ owner: issue.owner,
819
+ repo: issue.repo,
820
+ number: issue.number,
821
+ plan: opts.plan,
822
+ etaDays: opts.etaDays,
823
+ queueOk: opts.queueOk,
824
+ githubToken,
825
+ });
826
+ return {
827
+ outcome: direct.posted ? "claimed" : "direct-fallback",
828
+ path,
829
+ note,
830
+ commentUrl: direct.commentUrl,
831
+ commentBody: direct.commentBody,
832
+ login,
833
+ message: direct.posted ? `Claim posted: ${direct.commentUrl}` : "Direct path did not post.",
834
+ };
835
+ }
836
+ async function viaApiMarkerWrite(kind, issue, opts, deps) {
837
+ const print = deps.print ?? console.log;
838
+ const base = resolveApiBase({ flag: opts.apiBaseFlag, env: deps.env, home: deps.home });
839
+ const unauthenticated = new ClaimApiClient({ base, fetchImpl: deps.fetchImpl });
840
+ const { path, note } = await chooseViaApiPath(unauthenticated, issue.owner, issue.repo);
841
+ if (note)
842
+ print(note);
843
+ const dryBody = kind === "release"
844
+ ? "<!-- greenlight-claim release -->\n\nReleased under the Greenlight Claim Protocol: the next claim in the queue may now promote."
845
+ : `<!-- greenlight-claim v1 -->\n\nHeartbeat under the Greenlight Claim Protocol (${opts.mode === "reaction" ? "reaction" : "comment"} mode).`;
846
+ if (!opts.yes) {
847
+ return {
848
+ outcome: "done",
849
+ path,
850
+ note,
851
+ message: `Dry run: ${kind} NOT posted (path: ${path}). Re-run with --yes to post it.\n---\n${dryBody}\n---`,
852
+ };
853
+ }
854
+ const resolved = await resolveCall(opts, deps);
855
+ if ("outcome" in resolved)
856
+ return { outcome: "no-token", path: null, note: null, message: resolved.message };
857
+ const { client, login } = resolved;
858
+ if (path === "app") {
859
+ let write;
860
+ try {
861
+ write = await withReauth(resolved, deps, (c) => kind === "release"
862
+ ? c.postRelease(issue.owner, issue.repo, issue.number)
863
+ : c.postHeartbeat(issue.owner, issue.repo, issue.number, opts.mode));
864
+ }
865
+ catch (err) {
866
+ // 409 no-active-claim: there is nothing to release or renew. Render
867
+ // the server's message instead of a raw HTTP error.
868
+ if (err instanceof ApiError && err.status === 409) {
869
+ return { outcome: "refused", path, note, login, message: err.message };
870
+ }
871
+ throw err;
872
+ }
873
+ if (!write.ok)
874
+ return { outcome: "auth-failed", path, note, login, message: write.message };
875
+ const data = write.value;
876
+ let confirmed = false;
877
+ try {
878
+ const reread = await client.issueStatus(issue.owner, issue.repo, issue.number);
879
+ confirmed = kind === "release" ? !holderFromStatus(reread) : statusConfirmsLogin(reread, login);
880
+ }
881
+ catch {
882
+ /* best-effort */
883
+ }
884
+ const tail = confirmed ? "" : ` (posted, but the follow-up status read did not confirm it; check the issue thread)`;
885
+ return {
886
+ outcome: "done",
887
+ path,
888
+ note,
889
+ commentUrl: data.comment_url,
890
+ login,
891
+ message: `${kind === "release" ? "Release" : "Heartbeat"} posted: ${data.comment_url}${tail}`,
892
+ };
893
+ }
894
+ const githubToken = await githubTokenForDirect(resolved, deps);
895
+ if (!githubToken) {
896
+ return {
897
+ outcome: "refused",
898
+ path,
899
+ note,
900
+ login,
901
+ message: "The app is not installed, so this would post as a plain protocol comment, but no GitHub token is available for the direct path. Set GITHUB_TOKEN, or run greenlight auth login (device flow).",
902
+ };
903
+ }
904
+ const marker = deps.directMarker ??
905
+ (async (a) => {
906
+ const { createRestClient } = await import("./automate.js");
907
+ const prev = process.env.GITHUB_TOKEN;
908
+ if (!prev)
909
+ process.env.GITHUB_TOKEN = a.githubToken;
910
+ try {
911
+ const url = await createRestClient().postIssueComment(a.owner, a.repo, a.number, a.body);
912
+ return { posted: true, commentUrl: url };
913
+ }
914
+ finally {
915
+ if (!prev)
916
+ delete process.env.GITHUB_TOKEN;
917
+ }
918
+ });
919
+ const direct = await marker({ owner: issue.owner, repo: issue.repo, number: issue.number, body: dryBody, githubToken });
920
+ return {
921
+ outcome: direct.posted ? "done" : "refused",
922
+ path,
923
+ note,
924
+ commentUrl: direct.commentUrl,
925
+ login,
926
+ message: direct.posted ? `${kind === "release" ? "Release" : "Heartbeat"} posted: ${direct.commentUrl}` : "Direct path did not post.",
927
+ };
928
+ }
929
+ export function viaApiRelease(issue, opts, deps = {}) {
930
+ return viaApiMarkerWrite("release", issue, opts, deps);
931
+ }
932
+ export function viaApiHeartbeat(issue, opts, deps = {}) {
933
+ return viaApiMarkerWrite("heartbeat", issue, opts, deps);
934
+ }
935
+ function defaultParseIssueUrl(url) {
936
+ const m = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/.exec(url.trim());
937
+ if (!m)
938
+ throw new Error(`Expected a GitHub issue URL, got: ${url}`);
939
+ return { owner: m[1], repo: m[2], number: Number(m[3]) };
940
+ }
941
+ /**
942
+ * api_status: the hosted status payload for one issue, for agents that
943
+ * want the 60-second-fresh answer instead of the 15-minute static one.
944
+ * Read only; no token needed.
945
+ */
946
+ export async function mcpApiStatus(args, deps = {}) {
947
+ try {
948
+ const parse = deps.parseIssueUrl ?? defaultParseIssueUrl;
949
+ const { owner, repo, number } = parse(args.issue_url);
950
+ const base = resolveApiBase({ flag: args.api_base, env: deps.env, home: deps.home });
951
+ const client = new ClaimApiClient({ base, fetchImpl: deps.fetchImpl });
952
+ return await client.issueStatus(owner, repo, number);
953
+ }
954
+ catch (err) {
955
+ return { error: err instanceof Error ? err.message : String(err) };
956
+ }
957
+ }
958
+ /**
959
+ * claim_via_api: claim one issue through the hosted claim API.
960
+ * Report-mode-safe: execute defaults to false (dry run returns the plan);
961
+ * executes only with execute true and a stored token (else it refuses
962
+ * with the auth login instructions).
963
+ */
964
+ export async function mcpClaimViaApi(args, deps = {}) {
965
+ try {
966
+ const parse = deps.parseIssueUrl ?? defaultParseIssueUrl;
967
+ const { owner, repo, number } = parse(args.issue_url);
968
+ const printed = [];
969
+ const result = await viaApiClaim({ owner, repo, number, url: args.issue_url }, {
970
+ plan: args.plan,
971
+ etaDays: args.eta_days,
972
+ queueOk: args.queue_ok,
973
+ tokenFlag: args.token,
974
+ apiBaseFlag: args.api_base,
975
+ yes: args.execute === true,
976
+ }, { ...deps, print: (line) => printed.push(line) });
977
+ return { ...result, printed };
978
+ }
979
+ catch (err) {
980
+ return { error: err instanceof Error ? err.message : String(err) };
981
+ }
982
+ }
983
+ /** heartbeat_via_api: renew one claim through the hosted claim API. Same report-mode safety. */
984
+ export async function mcpHeartbeatViaApi(args, deps = {}) {
985
+ try {
986
+ const parse = deps.parseIssueUrl ?? defaultParseIssueUrl;
987
+ const { owner, repo, number } = parse(args.issue_url);
988
+ const printed = [];
989
+ const result = await viaApiHeartbeat({ owner, repo, number, url: args.issue_url }, { tokenFlag: args.token, apiBaseFlag: args.api_base, mode: args.mode, yes: args.execute === true }, { ...deps, print: (line) => printed.push(line) });
990
+ return { ...result, printed };
991
+ }
992
+ catch (err) {
993
+ return { error: err instanceof Error ? err.message : String(err) };
994
+ }
995
+ }
996
+ //# sourceMappingURL=claim-api.js.map