bikeshed-now 0.6.0 → 0.10.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,27 @@ 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.
30
+
31
+ `bikeshed logout` signs this computer out. It revokes a token from browser
32
+ sign-in as well as deleting it. A pasted token is only deleted, because CI may
33
+ be using the same one: revoke it at `/account` once nothing needs it.
20
34
 
21
35
  ## The loop
22
36
 
@@ -27,13 +41,17 @@ bikeshed publish proposal.md --title "Storage migration"
27
41
  # Somebody highlights a sentence and says what is wrong with it.
28
42
  bikeshed comments storage-migration
29
43
 
30
- # Fix that sentence, publish the revision, and say what you changed.
31
- bikeshed update storage-migration proposal.md
44
+ # Fix that sentence, say what you changed in its thread, then publish the
45
+ # revision. Reply first: a thread belongs to the version it was written on.
32
46
  bikeshed reply storage-migration <thread> "Rewritten to describe the dual-write period."
47
+ bikeshed update storage-migration proposal.md
33
48
 
34
49
  # Given "Can suggest" rather than "Can edit"? Propose the change instead, for a
35
50
  # person to accept or reject.
36
51
  bikeshed suggest storage-migration "a single weekend" --with "two weekends"
52
+
53
+ # Something nobody has raised yet? Start a thread on the passage it is about.
54
+ bikeshed comment storage-migration "a single weekend" --body "Is the dual-write fortnight in this?"
37
55
  ```
38
56
 
39
57
  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.10.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.10.0";
8
8
 
9
9
  /**
10
10
  * Three surfaces, and the split is deliberate rather than untidy.
@@ -165,7 +165,100 @@ 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. */
228
+ /**
229
+ * Revoke the token being presented, and only it (SPEC §23).
230
+ *
231
+ * A 401 from /tokens means one of two things that need different advice: the
232
+ * token had already stopped working, or the deployment is older than a token
233
+ * revoking itself and still takes only a browser session there. One call with
234
+ * the same token to /mcp tells them apart. Returns "revoked", "invalid" or
235
+ * "unsupported"; a deployment that cannot be reached throws.
236
+ */
237
+ export async function revokeOwnToken(origin, token) {
238
+ const res = await fetch(`${origin}/tokens`, {
239
+ method: "POST",
240
+ headers: headers(token, { "Content-Type": "application/json" }),
241
+ body: JSON.stringify({ op: "revoke" }),
242
+ });
243
+ if (res.ok) return "revoked";
244
+ if (res.status !== 401) {
245
+ throw new CliError(await readError(res, `the server returned ${res.status}`));
246
+ }
247
+ const probe = await fetch(`${origin}/mcp`, {
248
+ method: "POST",
249
+ headers: headers(token, { "Content-Type": "application/json" }),
250
+ body: JSON.stringify({
251
+ jsonrpc: "2.0",
252
+ id: 1,
253
+ method: "tools/call",
254
+ params: { name: "list_documents", arguments: { limit: 1 } },
255
+ }),
256
+ });
257
+ if (probe.status === 401) return "invalid";
258
+ if (probe.ok) return "unsupported";
259
+ throw new CliError(`the server returned ${probe.status}`);
260
+ }
261
+
169
262
  export async function whoami(origin, token) {
170
263
  // list_documents is the cheapest authenticated call, and a token that can
171
264
  // list is a token that works.
package/src/index.mjs CHANGED
@@ -11,23 +11,31 @@ 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,
19
+ revokeOwnToken,
17
20
  uploadFile,
18
21
  whoami,
19
22
  } from "./client.mjs";
23
+ import {
24
+ CLI_CLIENT_ID,
25
+ LOGIN_TIMEOUT_MS,
26
+ authorizeUrl,
27
+ canOpenBrowser,
28
+ createPkce,
29
+ createState,
30
+ deviceName,
31
+ discover,
32
+ listenForCallback,
33
+ openBrowser,
34
+ } from "./login.mjs";
20
35
 
21
36
  /**
22
- * This is the END-USER tool, and it is deliberately not the same thing as the
23
- * scripts in `scripts/`. Conflating the two is how somebody runs a teardown
24
- * against production, so the difference is stated in `--help`, in the README,
25
- * and here:
26
- *
27
- * scripts/*.mjs whoever owns the deployment. Admin credentials. Talks to
28
- * Firestore and Storage directly. Provisions and withdraws.
29
- * bikeshed any signed-up user. A personal access token. Talks to the
30
- * deployment over HTTPS. Publishes and revises documents.
37
+ * A person's own client: their personal access token, over HTTPS, able to do
38
+ * exactly what they can do in the browser and nothing more.
31
39
  *
32
40
  * Every command takes `--json`, so it composes inside a script or an agent's
33
41
  * shell without anybody parsing human-readable output.
@@ -35,7 +43,8 @@ import {
35
43
 
36
44
  const USAGE = `bikeshed — publish and revise documents
37
45
 
38
- bikeshed login store a personal access token
46
+ bikeshed login sign in through your browser (--paste for a token)
47
+ bikeshed logout sign out (revokes a token from browser sign-in)
39
48
  bikeshed list documents you can read
40
49
  bikeshed publish <file> create a new document
41
50
  bikeshed update <id> <file> publish a new version
@@ -43,6 +52,8 @@ const USAGE = `bikeshed — publish and revise documents
43
52
  bikeshed lineage <id> what it was forked from; for its owner, its forks
44
53
  bikeshed get <id> print a document's source
45
54
  bikeshed comments <id> unresolved feedback, with its anchored text
55
+ bikeshed comment <id> <text> --body <comment>
56
+ start a thread on a passage
46
57
  bikeshed reply <id> <thread> <text> reply in a thread
47
58
  bikeshed suggest <id> <text> --with <new text>
48
59
  propose new text for a passage, for a
@@ -92,16 +103,14 @@ Options
92
103
  --prefix <text> with suggest: words just before a passage that repeats
93
104
  --suffix <text> with suggest: words just after it
94
105
  --watch with publish/update: republish on every save
106
+ --paste with login: paste a token made at /account instead
107
+ --no-browser with login: print the sign-in link without opening it
95
108
  --origin <url> the deployment (default: the one you logged in to)
96
109
  --token <token> override the stored token
97
110
  --help this
98
111
 
99
112
  Environment
100
113
  BIKESHED_ORIGIN, BIKESHED_TOKEN, BIKESHED_AGENT
101
-
102
- This is the end-user tool. The scripts in scripts/ are the OPERATOR tools: they
103
- use admin credentials, talk to the database directly, and can tear a deployment
104
- down. They are not the same thing.
105
114
  `;
106
115
 
107
116
  function fail(message) {
@@ -146,7 +155,142 @@ function isArchive(path, bytes) {
146
155
 
147
156
  // --- Commands --------------------------------------------------------------
148
157
 
158
+ /**
159
+ * Sign in. Through the browser by default, by pasting a token with --paste.
160
+ *
161
+ * The browser flow needs no terminal on stdin, on purpose: an agent's shell
162
+ * has none, and an agent running `npx -y bikeshed-now login` on a person's
163
+ * behalf is the case it exists for. The person approves in their own browser;
164
+ * the agent never sees the token, and neither does anybody reading its
165
+ * output. So the address goes to stderr, stdout stays parseable for --json,
166
+ * and the token is never printed.
167
+ */
149
168
  async function login(flags) {
169
+ if (flags.paste) return pasteLogin(flags);
170
+ // A job runner has no browser and no person to approve. Waiting five minutes
171
+ // to say so would be the slowest possible way to fail.
172
+ if (isCi(process.env.CI) && !flags["no-browser"]) {
173
+ fail("In CI, set BIKESHED_TOKEN and BIKESHED_ORIGIN.");
174
+ }
175
+ return browserLogin(flags);
176
+ }
177
+
178
+ function isCi(value) {
179
+ return Boolean(value) && value !== "0" && value.toLowerCase() !== "false";
180
+ }
181
+
182
+ async function browserLogin(flags) {
183
+ const interactive = Boolean(process.stdin.isTTY);
184
+ let where = resolveAuth(flags).origin;
185
+ if (interactive && !flags.origin) {
186
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
187
+ try {
188
+ where = (await rl.question(`Deployment [${where}]: `)).trim() || where;
189
+ } finally {
190
+ rl.close();
191
+ }
192
+ }
193
+ try {
194
+ const url = new URL(where);
195
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error();
196
+ where = url.origin;
197
+ } catch {
198
+ fail(`not a deployment address: ${where} (try https://bikeshed.now)`);
199
+ }
200
+
201
+ const meta = await discover(where);
202
+ if (!meta) {
203
+ // A deployment from before browser sign-in. A person at a terminal can
204
+ // still paste; anything else is told how, rather than left at a prompt
205
+ // nobody will answer.
206
+ if (interactive) {
207
+ console.error(`${where} predates browser sign-in, so this will ask for a token instead.\n`);
208
+ return pasteLogin({ ...flags, origin: where });
209
+ }
210
+ fail(`${where} predates browser sign-in. Create a token at ${where}/account and run \`bikeshed login --paste\`.`);
211
+ }
212
+
213
+ const { verifier, challenge } = createPkce();
214
+ const state = createState();
215
+ const timeoutMs = Number(process.env.BIKESHED_LOGIN_TIMEOUT_MS) || LOGIN_TIMEOUT_MS;
216
+ const callback = await listenForCallback({ state, issuer: meta.issuer, timeoutMs });
217
+ const resource = `${where}/mcp`;
218
+ const url = authorizeUrl(meta.authorization_endpoint, {
219
+ clientId: CLI_CLIENT_ID,
220
+ redirectUri: callback.redirectUri,
221
+ challenge,
222
+ state,
223
+ resource,
224
+ device: deviceName(),
225
+ });
226
+
227
+ const onInterrupt = () => {
228
+ callback.close();
229
+ console.error("\nCancelled. Nothing was saved.");
230
+ process.exit(130);
231
+ };
232
+ process.once("SIGINT", onInterrupt);
233
+
234
+ let answer;
235
+ try {
236
+ const here = canOpenBrowser();
237
+ const opening = !flags["no-browser"] && here && openBrowser(url);
238
+ console.error(
239
+ opening
240
+ ? `Opening your browser to sign in to ${where}. If it does not open, visit:`
241
+ : `To sign in to ${where}, open this in your browser:`,
242
+ );
243
+ console.error(`\n ${url}\n`);
244
+ if (!flags["no-browser"] && !here) {
245
+ // The redirect comes back to 127.0.0.1 on THIS machine, which a browser
246
+ // on another one cannot reach.
247
+ console.error("If your browser is on another computer, it cannot reach back here: use `bikeshed login --paste` instead.");
248
+ }
249
+ console.error("Waiting for you to approve… (Ctrl-C to cancel)");
250
+ answer = await callback.result;
251
+ } finally {
252
+ process.off("SIGINT", onInterrupt);
253
+ }
254
+
255
+ if (answer.error) {
256
+ if (answer.error === "access_denied") fail("You chose not to connect. Nothing was saved.");
257
+ fail(`Sign-in did not finish: ${printable(answer.description ?? answer.error)}. Nothing was saved.`);
258
+ }
259
+
260
+ const token = await exchangeCode(meta.token_endpoint, {
261
+ code: answer.code,
262
+ verifier,
263
+ redirectUri: callback.redirectUri,
264
+ clientId: CLI_CLIENT_ID,
265
+ resource,
266
+ });
267
+ // Checked before it is stored, the same as a pasted one.
268
+ await whoami(where, token);
269
+ // `via` is what lets `logout` revoke this token: one made by browser sign-in
270
+ // belongs to this computer alone, where a pasted one may be in CI as well.
271
+ writeConfig({ ...readConfig(), origin: where, token, via: "browser" });
272
+
273
+ // The environment outranks the file (resolveAuth), so a variable left over
274
+ // from an earlier session would quietly keep being used instead.
275
+ // A BIKESHED_ORIGIN naming this same deployment changes nothing.
276
+ const shadowing = [];
277
+ if (process.env.BIKESHED_TOKEN) shadowing.push("BIKESHED_TOKEN");
278
+ if (process.env.BIKESHED_ORIGIN && process.env.BIKESHED_ORIGIN.replace(/\/+$/, "") !== where) {
279
+ shadowing.push("BIKESHED_ORIGIN");
280
+ }
281
+ if (shadowing.length === 1) {
282
+ console.error(`${shadowing[0]} is set in this shell, and it outranks what was just saved. Unset it to use this sign-in.`);
283
+ } else if (shadowing.length === 2) {
284
+ 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.");
285
+ }
286
+
287
+ out(flags.json, { origin: where, saved: CONFIG_PATH, via: "browser" }, () =>
288
+ console.log(`Signed in to ${where}. Saved to ${CONFIG_PATH} (readable only by you).`),
289
+ );
290
+ }
291
+
292
+ /** Paste a token made at /account. The flow from before browser sign-in. */
293
+ async function pasteLogin(flags) {
150
294
  const { origin } = resolveAuth(flags);
151
295
  const rl = createInterface({ input: process.stdin, output: process.stdout });
152
296
  try {
@@ -162,13 +306,78 @@ async function login(flags) {
162
306
  // Verified before it is stored, so a typo fails here rather than on the
163
307
  // next command with a confusing message.
164
308
  await whoami(where, token);
165
- writeConfig({ ...readConfig(), origin: where, token });
309
+ writeConfig({ ...readConfig(), origin: where, token, via: "paste" });
166
310
  console.log(`\nSaved to ${CONFIG_PATH} (readable only by you).`);
167
311
  } finally {
168
312
  rl.close();
169
313
  }
170
314
  }
171
315
 
316
+ /**
317
+ * Sign this computer out.
318
+ *
319
+ * Only the SAVED sign-in, never BIKESHED_TOKEN or --token: those belong to a
320
+ * shell or a job, and revoking one would sign out something nobody meant to
321
+ * touch. A token from browser sign-in exists for this computer alone, so it is
322
+ * revoked on the deployment as well as forgotten here. A pasted one may be in
323
+ * CI too, so it is only forgotten, as is one saved before `login` recorded
324
+ * which kind it was (SPEC §23).
325
+ *
326
+ * The token is forgotten whatever the deployment says, and when it cannot be
327
+ * reached at all: after `logout`, this computer is signed out. `revoked` in
328
+ * --json means the deployment confirmed the token no longer works.
329
+ */
330
+ async function logout(flags) {
331
+ const config = readConfig();
332
+ const origin = String(config.origin || resolveAuth().origin).replace(/\/+$/, "");
333
+ const shadowed = () => {
334
+ if (process.env.BIKESHED_TOKEN) {
335
+ console.error("BIKESHED_TOKEN is set in this shell, so commands here still use it. Unset it to sign out here too.");
336
+ }
337
+ };
338
+
339
+ if (!config.token) {
340
+ out(flags.json, { origin, revoked: false, forgot: false }, () => console.log("Not signed in on this computer."));
341
+ shadowed();
342
+ return;
343
+ }
344
+
345
+ let outcome = "kept";
346
+ let why = "You pasted it in, so something else may use it too.";
347
+ if (config.via === "browser") {
348
+ try {
349
+ outcome = await revokeOwnToken(origin, config.token);
350
+ why = `${origin} is too old to revoke a token from the command line.`;
351
+ } catch (e) {
352
+ outcome = "failed";
353
+ why =
354
+ e instanceof CliError
355
+ ? `${origin} did not revoke it (${printable(e.message, 80)}).`
356
+ : `Could not reach ${origin} to revoke it (${printable(e.cause?.code ?? e.message, 80)}).`;
357
+ }
358
+ }
359
+
360
+ const kept = { ...config, origin };
361
+ delete kept.token;
362
+ delete kept.via;
363
+ writeConfig(kept);
364
+
365
+ const revoked = outcome === "revoked" || outcome === "invalid";
366
+ out(flags.json, { origin, revoked, forgot: true }, () =>
367
+ console.log(
368
+ outcome === "revoked"
369
+ ? `Signed out. ${origin} revoked the token, and it is gone from this computer.`
370
+ : outcome === "invalid"
371
+ ? `Signed out. The token had already stopped working on ${origin}, and it is gone from this computer.`
372
+ : "Signed out. The token is gone from this computer.",
373
+ ),
374
+ );
375
+ if (!revoked) {
376
+ console.error(`${why} It still works until you revoke it at ${origin}/account, under Access tokens.`);
377
+ }
378
+ shadowed();
379
+ }
380
+
172
381
  async function list(flags) {
173
382
  const { origin, token } = resolveAuth(flags);
174
383
  const page = (limit, cursor) => {
@@ -327,6 +536,29 @@ async function comments(flags, [ref]) {
327
536
  });
328
537
  }
329
538
 
539
+ /**
540
+ * Start a thread on one passage of a document's current version. The passage
541
+ * is quoted as a reader sees it, as with `suggest`; what to say about it is a
542
+ * named flag, so the two pieces of free text never run together.
543
+ */
544
+ async function comment(flags, [ref, ...rest]) {
545
+ const exact = rest.join(" ");
546
+ const usage = "usage: bikeshed comment <id> <text> --body <comment> [--prefix …] [--suffix …]";
547
+ if (!ref || !exact) fail(usage);
548
+ // An empty --body is what an unset shell variable sends; a thread that says
549
+ // nothing is a highlight somebody has to resolve for no reason.
550
+ if (typeof flags.body !== "string" || !flags.body.trim()) fail(usage);
551
+ const { origin, token } = resolveAuth(flags);
552
+ const result = await callTool(origin, token, "add_comment", {
553
+ ...docArgs(ref),
554
+ exact,
555
+ body: flags.body,
556
+ ...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
557
+ ...(typeof flags.suffix === "string" ? { suffix: flags.suffix } : {}),
558
+ });
559
+ out(flags.json, result, () => console.log(`opened ${result.threadId}`));
560
+ }
561
+
330
562
  async function reply(flags, [ref, threadId, ...rest]) {
331
563
  const body = rest.join(" ");
332
564
  if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
@@ -532,10 +764,10 @@ async function open(flags, [id]) {
532
764
  if (!id) fail("usage: bikeshed open <id>");
533
765
  const { origin } = resolveAuth(flags);
534
766
  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();
767
+ // Best effort, and the address is printed either way. This used to spawn
768
+ // `start`, which is a cmd.exe builtin rather than a program, so on Windows
769
+ // the spawn failed and, with nothing listening for the error, crashed.
770
+ openBrowser(url);
539
771
  console.log(url);
540
772
  }
541
773
 
@@ -563,6 +795,7 @@ async function watchFile(file, run) {
563
795
 
564
796
  const COMMANDS = {
565
797
  login,
798
+ logout,
566
799
  list,
567
800
  publish,
568
801
  update,
@@ -570,6 +803,7 @@ const COMMANDS = {
570
803
  lineage,
571
804
  get,
572
805
  comments,
806
+ comment,
573
807
  reply,
574
808
  suggest,
575
809
  open,
@@ -580,7 +814,7 @@ const COMMANDS = {
580
814
  };
581
815
 
582
816
  /** Commands that reach a deployment without authenticating as anybody. */
583
- const NO_TOKEN = new Set(["login", "open", "skills"]);
817
+ const NO_TOKEN = new Set(["login", "logout", "open", "skills"]);
584
818
 
585
819
  async function main() {
586
820
  const { values, positionals } = parseArgs({
@@ -603,7 +837,10 @@ async function main() {
603
837
  cursor: { type: "string" },
604
838
  with: { type: "string" },
605
839
  delete: { type: "boolean", default: false },
840
+ paste: { type: "boolean", default: false },
841
+ "no-browser": { type: "boolean", default: false },
606
842
  note: { type: "string" },
843
+ body: { type: "string" },
607
844
  prefix: { type: "string" },
608
845
  suffix: { type: "string" },
609
846
  },
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
+ }