bikeshed-now 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,13 +10,23 @@ works the same, with your address in place of `https://bikeshed.now`.
10
10
 
11
11
  ```bash
12
12
  npm i -g bikeshed-now
13
- bikeshed login # asks for the deployment and a token
13
+ bikeshed login # signs you in through your browser
14
14
  ```
15
15
 
16
- Create a token at [bikeshed.now/account](https://bikeshed.now/account) — choose
17
- **Command line**. It is shown once. `BIKESHED_ORIGIN` and `BIKESHED_TOKEN` work
18
- instead of `login` where an interactive command cannot run — a CI job, or an
19
- agent.
16
+ `login` opens your browser at the deployment. Sign in if you need to, click
17
+ Approve, and the terminal saves a token that only you can read. It prints the
18
+ link as well, for when no browser opens. Pass `--origin <url>` for your own
19
+ deployment, or `--no-browser` to open the link yourself. An agent can run
20
+ `npx -y bikeshed-now login` for you: you approve in your browser, and the token
21
+ never appears in its output.
22
+
23
+ The browser has to be on the same computer as the terminal, because it hands
24
+ the sign-in back to a port on `127.0.0.1`. Over SSH, or against a deployment
25
+ too old to offer browser sign-in, create a token at
26
+ [bikeshed.now/account](https://bikeshed.now/account), choose **Command line**,
27
+ and run `bikeshed login --paste`. The token is shown once. In CI, set
28
+ `BIKESHED_ORIGIN` and `BIKESHED_TOKEN` instead of running `login` at all; they
29
+ outrank whatever `login` saved.
20
30
 
21
31
  ## The loop
22
32
 
@@ -27,13 +37,17 @@ bikeshed publish proposal.md --title "Storage migration"
27
37
  # Somebody highlights a sentence and says what is wrong with it.
28
38
  bikeshed comments storage-migration
29
39
 
30
- # Fix that sentence, publish the revision, and say what you changed.
31
- bikeshed update storage-migration proposal.md
40
+ # Fix that sentence, say what you changed in its thread, then publish the
41
+ # revision. Reply first: a thread belongs to the version it was written on.
32
42
  bikeshed reply storage-migration <thread> "Rewritten to describe the dual-write period."
43
+ bikeshed update storage-migration proposal.md
33
44
 
34
45
  # Given "Can suggest" rather than "Can edit"? Propose the change instead, for a
35
46
  # person to accept or reject.
36
47
  bikeshed suggest storage-migration "a single weekend" --with "two weekends"
48
+
49
+ # Something nobody has raised yet? Start a thread on the passage it is about.
50
+ bikeshed comment storage-migration "a single weekend" --body "Is the dual-write fortnight in this?"
37
51
  ```
38
52
 
39
53
  Publishing identical bytes is a no-op: the bundle is hashed, and a matching hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.6.0",
3
+ "version": "0.9.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Publish and revise documents on a Bikeshed deployment from the terminal.",
@@ -26,7 +26,7 @@
26
26
  "bikeshed": "src/index.mjs"
27
27
  },
28
28
  "engines": {
29
- "node": ">=20"
29
+ "node": ">=22"
30
30
  },
31
31
  "files": [
32
32
  "src",
package/src/client.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { readFileSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
 
7
- export const CLIENT_NAME = "bikeshed-cli/0.6.0";
7
+ export const CLIENT_NAME = "bikeshed-cli/0.9.0";
8
8
 
9
9
  /**
10
10
  * Three surfaces, and the split is deliberate rather than untidy.
@@ -165,6 +165,65 @@ export async function fetchSkill(origin, skill) {
165
165
  return res.text();
166
166
  }
167
167
 
168
+ /**
169
+ * Text a server chose, made safe to print in a terminal: control characters
170
+ * (an escape sequence can rewrite what a terminal shows) go, and it is cut to
171
+ * a line's worth.
172
+ */
173
+ export function printable(text, max = 200) {
174
+ const clean = String(text ?? "").replace(/[\p{Cc}\p{Cf}]/gu, " ").trim();
175
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
176
+ }
177
+
178
+ /** The only thing the token endpoint may hand the CLI: a personal access token. */
179
+ const ACCESS_TOKEN_RE = /^bsk_[0-9a-f]{32}\.[A-Za-z0-9_-]{20,64}$/;
180
+
181
+ /**
182
+ * Trade a browser sign-in's code for a token (SPEC §23).
183
+ *
184
+ * Form-encoded, as OAuth defines the token request. Redirects are refused: a
185
+ * token endpoint that moves is not one to send a code and its verifier after.
186
+ * Whatever comes back has to be shaped like a personal access token before it
187
+ * is stored, because it is about to be saved as one and sent on every call.
188
+ */
189
+ export async function exchangeCode(tokenEndpoint, { code, verifier, redirectUri, clientId, resource }) {
190
+ const body = new URLSearchParams({
191
+ grant_type: "authorization_code",
192
+ code,
193
+ redirect_uri: redirectUri,
194
+ client_id: clientId,
195
+ code_verifier: verifier,
196
+ resource,
197
+ });
198
+ let res;
199
+ try {
200
+ res = await fetch(tokenEndpoint, {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
203
+ body,
204
+ redirect: "error",
205
+ });
206
+ } catch (e) {
207
+ throw new CliError(`could not finish signing in: ${e.cause?.code ?? e.message}. Nothing was saved.`);
208
+ }
209
+ const answer = await res.json().catch(() => null);
210
+
211
+ if (!res.ok) {
212
+ const error = typeof answer?.error === "string" ? answer.error : `HTTP ${res.status}`;
213
+ const detail = answer?.error_description ? `: ${printable(answer.error_description)}` : "";
214
+ const advice =
215
+ error === "invalid_grant"
216
+ ? "The sign-in code expired or was already used."
217
+ : "The deployment refused the sign-in.";
218
+ throw new CliError(`${advice} Nothing was saved; run \`bikeshed login\` again. (${printable(error, 60)}${detail})`);
219
+ }
220
+ const token = answer?.access_token;
221
+ if (typeof token !== "string" || !ACCESS_TOKEN_RE.test(token)) {
222
+ throw new CliError("The deployment answered with something that is not a Bikeshed token. Nothing was saved.");
223
+ }
224
+ return token;
225
+ }
226
+
168
227
  /** Check that a token works, and say who it belongs to. */
169
228
  export async function whoami(origin, token) {
170
229
  // list_documents is the cheapest authenticated call, and a token that can
package/src/index.mjs CHANGED
@@ -11,12 +11,26 @@ import { CONFIG_PATH, readConfig, resolveAuth, writeConfig } from "./config.mjs"
11
11
  import {
12
12
  CliError,
13
13
  callTool,
14
+ exchangeCode,
14
15
  fetchSkill,
15
16
  fetchSource,
16
17
  listSkills,
18
+ printable,
17
19
  uploadFile,
18
20
  whoami,
19
21
  } from "./client.mjs";
22
+ import {
23
+ CLI_CLIENT_ID,
24
+ LOGIN_TIMEOUT_MS,
25
+ authorizeUrl,
26
+ canOpenBrowser,
27
+ createPkce,
28
+ createState,
29
+ deviceName,
30
+ discover,
31
+ listenForCallback,
32
+ openBrowser,
33
+ } from "./login.mjs";
20
34
 
21
35
  /**
22
36
  * This is the END-USER tool, and it is deliberately not the same thing as the
@@ -35,7 +49,7 @@ import {
35
49
 
36
50
  const USAGE = `bikeshed — publish and revise documents
37
51
 
38
- bikeshed login store a personal access token
52
+ bikeshed login sign in through your browser (--paste for a token)
39
53
  bikeshed list documents you can read
40
54
  bikeshed publish <file> create a new document
41
55
  bikeshed update <id> <file> publish a new version
@@ -43,6 +57,8 @@ const USAGE = `bikeshed — publish and revise documents
43
57
  bikeshed lineage <id> what it was forked from; for its owner, its forks
44
58
  bikeshed get <id> print a document's source
45
59
  bikeshed comments <id> unresolved feedback, with its anchored text
60
+ bikeshed comment <id> <text> --body <comment>
61
+ start a thread on a passage
46
62
  bikeshed reply <id> <thread> <text> reply in a thread
47
63
  bikeshed suggest <id> <text> --with <new text>
48
64
  propose new text for a passage, for a
@@ -92,6 +108,8 @@ Options
92
108
  --prefix <text> with suggest: words just before a passage that repeats
93
109
  --suffix <text> with suggest: words just after it
94
110
  --watch with publish/update: republish on every save
111
+ --paste with login: paste a token made at /account instead
112
+ --no-browser with login: print the sign-in link without opening it
95
113
  --origin <url> the deployment (default: the one you logged in to)
96
114
  --token <token> override the stored token
97
115
  --help this
@@ -146,7 +164,140 @@ function isArchive(path, bytes) {
146
164
 
147
165
  // --- Commands --------------------------------------------------------------
148
166
 
167
+ /**
168
+ * Sign in. Through the browser by default, by pasting a token with --paste.
169
+ *
170
+ * The browser flow needs no terminal on stdin, on purpose: an agent's shell
171
+ * has none, and an agent running `npx -y bikeshed-now login` on a person's
172
+ * behalf is the case it exists for. The person approves in their own browser;
173
+ * the agent never sees the token, and neither does anybody reading its
174
+ * output. So the address goes to stderr, stdout stays parseable for --json,
175
+ * and the token is never printed.
176
+ */
149
177
  async function login(flags) {
178
+ if (flags.paste) return pasteLogin(flags);
179
+ // A job runner has no browser and no person to approve. Waiting five minutes
180
+ // to say so would be the slowest possible way to fail.
181
+ if (isCi(process.env.CI) && !flags["no-browser"]) {
182
+ fail("In CI, set BIKESHED_TOKEN and BIKESHED_ORIGIN.");
183
+ }
184
+ return browserLogin(flags);
185
+ }
186
+
187
+ function isCi(value) {
188
+ return Boolean(value) && value !== "0" && value.toLowerCase() !== "false";
189
+ }
190
+
191
+ async function browserLogin(flags) {
192
+ const interactive = Boolean(process.stdin.isTTY);
193
+ let where = resolveAuth(flags).origin;
194
+ if (interactive && !flags.origin) {
195
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
196
+ try {
197
+ where = (await rl.question(`Deployment [${where}]: `)).trim() || where;
198
+ } finally {
199
+ rl.close();
200
+ }
201
+ }
202
+ try {
203
+ const url = new URL(where);
204
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error();
205
+ where = url.origin;
206
+ } catch {
207
+ fail(`not a deployment address: ${where} (try https://bikeshed.now)`);
208
+ }
209
+
210
+ const meta = await discover(where);
211
+ if (!meta) {
212
+ // A deployment from before browser sign-in. A person at a terminal can
213
+ // still paste; anything else is told how, rather than left at a prompt
214
+ // nobody will answer.
215
+ if (interactive) {
216
+ console.error(`${where} predates browser sign-in, so this will ask for a token instead.\n`);
217
+ return pasteLogin({ ...flags, origin: where });
218
+ }
219
+ fail(`${where} predates browser sign-in. Create a token at ${where}/account and run \`bikeshed login --paste\`.`);
220
+ }
221
+
222
+ const { verifier, challenge } = createPkce();
223
+ const state = createState();
224
+ const timeoutMs = Number(process.env.BIKESHED_LOGIN_TIMEOUT_MS) || LOGIN_TIMEOUT_MS;
225
+ const callback = await listenForCallback({ state, issuer: meta.issuer, timeoutMs });
226
+ const resource = `${where}/mcp`;
227
+ const url = authorizeUrl(meta.authorization_endpoint, {
228
+ clientId: CLI_CLIENT_ID,
229
+ redirectUri: callback.redirectUri,
230
+ challenge,
231
+ state,
232
+ resource,
233
+ device: deviceName(),
234
+ });
235
+
236
+ const onInterrupt = () => {
237
+ callback.close();
238
+ console.error("\nCancelled. Nothing was saved.");
239
+ process.exit(130);
240
+ };
241
+ process.once("SIGINT", onInterrupt);
242
+
243
+ let answer;
244
+ try {
245
+ const here = canOpenBrowser();
246
+ const opening = !flags["no-browser"] && here && openBrowser(url);
247
+ console.error(
248
+ opening
249
+ ? `Opening your browser to sign in to ${where}. If it does not open, visit:`
250
+ : `To sign in to ${where}, open this in your browser:`,
251
+ );
252
+ console.error(`\n ${url}\n`);
253
+ if (!flags["no-browser"] && !here) {
254
+ // The redirect comes back to 127.0.0.1 on THIS machine, which a browser
255
+ // on another one cannot reach.
256
+ console.error("If your browser is on another computer, it cannot reach back here: use `bikeshed login --paste` instead.");
257
+ }
258
+ console.error("Waiting for you to approve… (Ctrl-C to cancel)");
259
+ answer = await callback.result;
260
+ } finally {
261
+ process.off("SIGINT", onInterrupt);
262
+ }
263
+
264
+ if (answer.error) {
265
+ if (answer.error === "access_denied") fail("You chose not to connect. Nothing was saved.");
266
+ fail(`Sign-in did not finish: ${printable(answer.description ?? answer.error)}. Nothing was saved.`);
267
+ }
268
+
269
+ const token = await exchangeCode(meta.token_endpoint, {
270
+ code: answer.code,
271
+ verifier,
272
+ redirectUri: callback.redirectUri,
273
+ clientId: CLI_CLIENT_ID,
274
+ resource,
275
+ });
276
+ // Checked before it is stored, the same as a pasted one.
277
+ await whoami(where, token);
278
+ writeConfig({ ...readConfig(), origin: where, token });
279
+
280
+ // The environment outranks the file (resolveAuth), so a variable left over
281
+ // from an earlier session would quietly keep being used instead.
282
+ // A BIKESHED_ORIGIN naming this same deployment changes nothing.
283
+ const shadowing = [];
284
+ if (process.env.BIKESHED_TOKEN) shadowing.push("BIKESHED_TOKEN");
285
+ if (process.env.BIKESHED_ORIGIN && process.env.BIKESHED_ORIGIN.replace(/\/+$/, "") !== where) {
286
+ shadowing.push("BIKESHED_ORIGIN");
287
+ }
288
+ if (shadowing.length === 1) {
289
+ console.error(`${shadowing[0]} is set in this shell, and it outranks what was just saved. Unset it to use this sign-in.`);
290
+ } else if (shadowing.length === 2) {
291
+ console.error("BIKESHED_TOKEN and BIKESHED_ORIGIN are set in this shell, and they outrank what was just saved. Unset them to use this sign-in.");
292
+ }
293
+
294
+ out(flags.json, { origin: where, saved: CONFIG_PATH, via: "browser" }, () =>
295
+ console.log(`Signed in to ${where}. Saved to ${CONFIG_PATH} (readable only by you).`),
296
+ );
297
+ }
298
+
299
+ /** Paste a token made at /account. The flow from before browser sign-in. */
300
+ async function pasteLogin(flags) {
150
301
  const { origin } = resolveAuth(flags);
151
302
  const rl = createInterface({ input: process.stdin, output: process.stdout });
152
303
  try {
@@ -327,6 +478,29 @@ async function comments(flags, [ref]) {
327
478
  });
328
479
  }
329
480
 
481
+ /**
482
+ * Start a thread on one passage of a document's current version. The passage
483
+ * is quoted as a reader sees it, as with `suggest`; what to say about it is a
484
+ * named flag, so the two pieces of free text never run together.
485
+ */
486
+ async function comment(flags, [ref, ...rest]) {
487
+ const exact = rest.join(" ");
488
+ const usage = "usage: bikeshed comment <id> <text> --body <comment> [--prefix …] [--suffix …]";
489
+ if (!ref || !exact) fail(usage);
490
+ // An empty --body is what an unset shell variable sends; a thread that says
491
+ // nothing is a highlight somebody has to resolve for no reason.
492
+ if (typeof flags.body !== "string" || !flags.body.trim()) fail(usage);
493
+ const { origin, token } = resolveAuth(flags);
494
+ const result = await callTool(origin, token, "add_comment", {
495
+ ...docArgs(ref),
496
+ exact,
497
+ body: flags.body,
498
+ ...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
499
+ ...(typeof flags.suffix === "string" ? { suffix: flags.suffix } : {}),
500
+ });
501
+ out(flags.json, result, () => console.log(`opened ${result.threadId}`));
502
+ }
503
+
330
504
  async function reply(flags, [ref, threadId, ...rest]) {
331
505
  const body = rest.join(" ");
332
506
  if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
@@ -532,10 +706,10 @@ async function open(flags, [id]) {
532
706
  if (!id) fail("usage: bikeshed open <id>");
533
707
  const { origin } = resolveAuth(flags);
534
708
  const url = `${origin}/d/${id}`;
535
- const { spawn } = await import("node:child_process");
536
- const opener =
537
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
538
- spawn(opener, [url], { stdio: "ignore", detached: true }).unref();
709
+ // Best effort, and the address is printed either way. This used to spawn
710
+ // `start`, which is a cmd.exe builtin rather than a program, so on Windows
711
+ // the spawn failed and, with nothing listening for the error, crashed.
712
+ openBrowser(url);
539
713
  console.log(url);
540
714
  }
541
715
 
@@ -570,6 +744,7 @@ const COMMANDS = {
570
744
  lineage,
571
745
  get,
572
746
  comments,
747
+ comment,
573
748
  reply,
574
749
  suggest,
575
750
  open,
@@ -603,7 +778,10 @@ async function main() {
603
778
  cursor: { type: "string" },
604
779
  with: { type: "string" },
605
780
  delete: { type: "boolean", default: false },
781
+ paste: { type: "boolean", default: false },
782
+ "no-browser": { type: "boolean", default: false },
606
783
  note: { type: "string" },
784
+ body: { type: "string" },
607
785
  prefix: { type: "string" },
608
786
  suffix: { type: "string" },
609
787
  },
package/src/login.mjs ADDED
@@ -0,0 +1,298 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Browser sign-in for the command line: PKCE over a loopback redirect (SPEC §23)
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
+ import { createServer } from "node:http";
7
+ import { hostname } from "node:os";
8
+
9
+ import { CliError } from "./client.mjs";
10
+
11
+ /**
12
+ * How `bikeshed login` gets a token without anybody copying one.
13
+ *
14
+ * The CLI listens on a random port on 127.0.0.1, opens the deployment's
15
+ * consent page with that address as the redirect, and waits for the browser
16
+ * to come back with a one-time code. The code is worth nothing without the
17
+ * PKCE verifier, which never leaves this process, so a code that leaks through
18
+ * browser history or another program on the machine cannot be redeemed.
19
+ *
20
+ * What comes back is an ordinary personal access token (SPEC §19). The browser
21
+ * only replaces the copying.
22
+ */
23
+
24
+ /** The built-in client the deployment knows without a registration. */
25
+ export const CLI_CLIENT_ID = "bikeshed-cli";
26
+
27
+ /** How long to wait for the browser before giving up. */
28
+ export const LOGIN_TIMEOUT_MS = 5 * 60_000;
29
+
30
+ export function createPkce() {
31
+ const verifier = randomBytes(32).toString("base64url");
32
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
33
+ return { verifier, challenge };
34
+ }
35
+
36
+ export function createState() {
37
+ return randomBytes(16).toString("base64url");
38
+ }
39
+
40
+ /**
41
+ * What the consent page calls this machine, so a person can tell two
42
+ * terminals apart in their account. A hostname is whatever its owner typed,
43
+ * so control and formatting characters go, and it is cut to fit a row.
44
+ */
45
+ export function deviceName(raw = hostname()) {
46
+ const clean = String(raw ?? "").replace(/[\p{Cc}\p{Cf}]/gu, "").trim();
47
+ return Array.from(clean).slice(0, 60).join("");
48
+ }
49
+
50
+ /**
51
+ * The deployment's authorization server metadata, or null when it predates
52
+ * browser sign-in.
53
+ *
54
+ * "Predates" has two shapes. A 404 is the honest one. Hosting's catch-all
55
+ * rewrite is the other: it answers a path it does not know with the app's
56
+ * index.html and a 200, so a body that is not JSON means the same thing as a
57
+ * 404 (the trap `listSkills` handles too).
58
+ *
59
+ * Anything that IS metadata has to describe this deployment and no other. An
60
+ * endpoint on another origin would send the person, or the code, somewhere
61
+ * they did not ask to sign in to.
62
+ */
63
+ export async function discover(origin) {
64
+ const base = new URL(origin).origin;
65
+ let res;
66
+ try {
67
+ res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
68
+ headers: { Accept: "application/json" },
69
+ });
70
+ } catch (e) {
71
+ throw new CliError(`could not reach ${base} (${e.cause?.code ?? e.message}).`);
72
+ }
73
+ if (res.status === 404) return null;
74
+ if (!res.ok) throw new CliError(`${base} answered ${res.status} when asked how to sign in.`);
75
+
76
+ const meta = await res.json().catch(() => null);
77
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
78
+
79
+ const unusable = (why) => new CliError(`${base} describes its sign-in in a way this client cannot use: ${why}.`);
80
+ if (meta.issuer !== base) {
81
+ throw unusable(`it calls itself ${JSON.stringify(meta.issuer)}, not ${base}`);
82
+ }
83
+ for (const key of ["authorization_endpoint", "token_endpoint"]) {
84
+ let endpoint;
85
+ try {
86
+ endpoint = new URL(meta[key]);
87
+ } catch {
88
+ throw unusable(`${key} is not an address`);
89
+ }
90
+ if (endpoint.origin !== base) throw unusable(`${key} is on ${endpoint.origin}`);
91
+ }
92
+ if (!Array.isArray(meta.code_challenge_methods_supported) || !meta.code_challenge_methods_supported.includes("S256")) {
93
+ throw unusable("it does not offer S256 PKCE");
94
+ }
95
+ return meta;
96
+ }
97
+
98
+ /** The consent page's address, with every value encoded by URL itself. */
99
+ export function authorizeUrl(endpoint, { clientId, redirectUri, challenge, state, resource, device }) {
100
+ const url = new URL(endpoint);
101
+ url.searchParams.set("response_type", "code");
102
+ url.searchParams.set("client_id", clientId);
103
+ url.searchParams.set("redirect_uri", redirectUri);
104
+ url.searchParams.set("code_challenge", challenge);
105
+ url.searchParams.set("code_challenge_method", "S256");
106
+ url.searchParams.set("state", state);
107
+ url.searchParams.set("resource", resource);
108
+ if (device) url.searchParams.set("device", device);
109
+ return url.toString();
110
+ }
111
+
112
+ // --- The loopback listener --------------------------------------------------
113
+
114
+ export const PAGE_HEADERS = {
115
+ "Content-Type": "text/html; charset=utf-8",
116
+ "Cache-Control": "no-store",
117
+ "Referrer-Policy": "no-referrer",
118
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
119
+ "X-Content-Type-Options": "nosniff",
120
+ };
121
+
122
+ const PAGES = {
123
+ ok: ["Signed in", "You're signed in. You can close this tab and go back to your terminal."],
124
+ denied: ["Not connected", "You chose not to connect the command line, so nothing was saved. You can close this tab."],
125
+ failed: ["Sign-in didn't finish", "Go back to your terminal, which says what happened. You can close this tab."],
126
+ };
127
+
128
+ /**
129
+ * The page the browser lands on. Fixed text, chosen by kind: nothing from the
130
+ * query string is ever written into it, so a crafted callback has nothing to
131
+ * inject into.
132
+ */
133
+ export function callbackPage(kind) {
134
+ const [title, text] = PAGES[kind] ?? PAGES.failed;
135
+ return `<!doctype html>
136
+ <html lang="en">
137
+ <head>
138
+ <meta charset="utf-8">
139
+ <meta name="viewport" content="width=device-width, initial-scale=1">
140
+ <title>${title} · Bikeshed</title>
141
+ <style>
142
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 20vh 16px 0; text-align: center; color: #1c1c1c; background: #fafafa; }
143
+ @media (prefers-color-scheme: dark) { body { color: #ececec; background: #161616; } }
144
+ h1 { font-size: 1.4rem; margin: 0 0 8px; }
145
+ p { margin: 0 auto; max-width: 32rem; }
146
+ </style>
147
+ </head>
148
+ <body>
149
+ <h1>${title}</h1>
150
+ <p>${text}</p>
151
+ </body>
152
+ </html>
153
+ `;
154
+ }
155
+
156
+ function sameSecret(given, expected) {
157
+ const a = Buffer.from(String(given ?? ""), "utf8");
158
+ const b = Buffer.from(expected, "utf8");
159
+ return a.length === b.length && timingSafeEqual(a, b);
160
+ }
161
+
162
+ /**
163
+ * Wait for the browser to come back.
164
+ *
165
+ * 127.0.0.1 only, and spelled as that literal in the redirect: `localhost` can
166
+ * resolve to ::1 first, or to anything a hosts file says. The Host header is
167
+ * checked too, because a web page that rebinds its own name to 127.0.0.1 can
168
+ * reach this port, but cannot make its requests say this port's name.
169
+ *
170
+ * Only a request carrying this attempt's `state` ends the wait. Anything else
171
+ * — another path, a stale tab, a forged callback — gets an error page and the
172
+ * wait goes on. The first request that does carry it is the last one served:
173
+ * the listener closes before the code is exchanged.
174
+ */
175
+ export async function listenForCallback({ state, issuer, timeoutMs = LOGIN_TIMEOUT_MS }) {
176
+ let settle;
177
+ const result = new Promise((resolve, reject) => {
178
+ settle = { resolve, reject };
179
+ });
180
+ let done = false;
181
+ let timer;
182
+ let port;
183
+
184
+ const server = createServer((req, res) => {
185
+ const reply = (status, kind, extra = {}) => {
186
+ const body = callbackPage(kind);
187
+ res.writeHead(status, { ...PAGE_HEADERS, "Content-Length": Buffer.byteLength(body), ...extra });
188
+ res.end(body);
189
+ };
190
+
191
+ if (req.headers.host !== `127.0.0.1:${port}`) return reply(400, "failed");
192
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
193
+ if (req.method !== "GET" || url.pathname !== "/callback") return reply(404, "failed");
194
+ if (done || !sameSecret(url.searchParams.get("state"), state)) return reply(400, "failed");
195
+
196
+ // This attempt's answer. Whatever it says, it is the only one.
197
+ done = true;
198
+ clearTimeout(timer);
199
+ server.close();
200
+ res.on("finish", () => server.closeAllConnections());
201
+
202
+ const params = url.searchParams;
203
+ if (params.get("iss") !== issuer) {
204
+ // A response for this request from some other server is the mix-up
205
+ // attack RFC 9207 exists for. Its code is not sent anywhere.
206
+ reply(400, "failed", { Connection: "close" });
207
+ settle.reject(new CliError("The sign-in came back from a different server than the one you are signing in to. Nothing was saved."));
208
+ return;
209
+ }
210
+ const error = params.get("error");
211
+ if (error) {
212
+ reply(200, error === "access_denied" ? "denied" : "failed", { Connection: "close" });
213
+ const description = params.get("error_description");
214
+ settle.resolve({ error, ...(description ? { description } : {}) });
215
+ return;
216
+ }
217
+ const code = params.get("code");
218
+ if (!code) {
219
+ reply(400, "failed", { Connection: "close" });
220
+ settle.resolve({ error: "invalid_response", description: "the browser came back without a code" });
221
+ return;
222
+ }
223
+ reply(200, "ok", { Connection: "close" });
224
+ settle.resolve({ code });
225
+ });
226
+
227
+ await new Promise((resolve, reject) => {
228
+ server.once("error", reject);
229
+ server.listen(0, "127.0.0.1", resolve);
230
+ });
231
+ port = server.address().port;
232
+
233
+ const close = (reason) => {
234
+ clearTimeout(timer);
235
+ server.close();
236
+ server.closeAllConnections();
237
+ if (!done) {
238
+ done = true;
239
+ settle.reject(reason ?? new CliError("Sign-in was cancelled. Nothing was saved."));
240
+ }
241
+ };
242
+ timer = setTimeout(
243
+ () => close(new CliError("Nothing came back from the browser in time. Nothing was saved; run `bikeshed login` to try again.")),
244
+ timeoutMs,
245
+ );
246
+
247
+ return { redirectUri: `http://127.0.0.1:${port}/callback`, result, close: () => close() };
248
+ }
249
+
250
+ // --- Opening a browser ------------------------------------------------------
251
+
252
+ /**
253
+ * The program that opens a web address, and its arguments.
254
+ *
255
+ * Never a shell. On Windows the usual answer is `cmd /c start`, and cmd splits
256
+ * its command line at `&`, which every query string contains: the browser
257
+ * would get the address up to the first parameter, and cmd would try to run
258
+ * the rest as commands. rundll32 takes the whole address as one argument.
259
+ *
260
+ * Only http and https, which also rules out an "address" that starts with `-`
261
+ * and reads to the opener as an option.
262
+ */
263
+ export function browserCommand(platform, url) {
264
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) {
265
+ throw new CliError(`refusing to open something that is not a web address: ${url}`);
266
+ }
267
+ if (platform === "darwin") return ["open", [url]];
268
+ if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
269
+ return ["xdg-open", [url]];
270
+ }
271
+
272
+ /**
273
+ * Open a web address, best effort. The caller prints the address either way,
274
+ * so a missing opener (no xdg-open, a locked-down machine) costs nothing; it
275
+ * must never crash the command, which an unhandled spawn error would.
276
+ */
277
+ export function openBrowser(url, platform = process.platform, run = spawn) {
278
+ try {
279
+ const [command, args] = browserCommand(platform, url);
280
+ run(command, args, { stdio: "ignore", detached: true, shell: false })
281
+ .on("error", () => {})
282
+ .unref();
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Whether a browser opened from here would be one the person is looking at.
291
+ * Over SSH it would open on the remote machine, or nowhere; without a display
292
+ * xdg-open has nothing to open it on.
293
+ */
294
+ export function canOpenBrowser(env = process.env, platform = process.platform) {
295
+ if (env.SSH_CONNECTION || env.SSH_TTY) return false;
296
+ if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false;
297
+ return true;
298
+ }