bikeshed-now 0.9.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
@@ -28,6 +28,10 @@ and run `bikeshed login --paste`. The token is shown once. In CI, set
28
28
  `BIKESHED_ORIGIN` and `BIKESHED_TOKEN` instead of running `login` at all; they
29
29
  outrank whatever `login` saved.
30
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.
34
+
31
35
  ## The loop
32
36
 
33
37
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.9.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.",
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.9.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.
@@ -225,6 +225,40 @@ export async function exchangeCode(tokenEndpoint, { code, verifier, redirectUri,
225
225
  }
226
226
 
227
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
+
228
262
  export async function whoami(origin, token) {
229
263
  // list_documents is the cheapest authenticated call, and a token that can
230
264
  // list is a token that works.
package/src/index.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  fetchSource,
17
17
  listSkills,
18
18
  printable,
19
+ revokeOwnToken,
19
20
  uploadFile,
20
21
  whoami,
21
22
  } from "./client.mjs";
@@ -33,15 +34,8 @@ import {
33
34
  } from "./login.mjs";
34
35
 
35
36
  /**
36
- * This is the END-USER tool, and it is deliberately not the same thing as the
37
- * scripts in `scripts/`. Conflating the two is how somebody runs a teardown
38
- * against production, so the difference is stated in `--help`, in the README,
39
- * and here:
40
- *
41
- * scripts/*.mjs whoever owns the deployment. Admin credentials. Talks to
42
- * Firestore and Storage directly. Provisions and withdraws.
43
- * bikeshed any signed-up user. A personal access token. Talks to the
44
- * 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.
45
39
  *
46
40
  * Every command takes `--json`, so it composes inside a script or an agent's
47
41
  * shell without anybody parsing human-readable output.
@@ -50,6 +44,7 @@ import {
50
44
  const USAGE = `bikeshed — publish and revise documents
51
45
 
52
46
  bikeshed login sign in through your browser (--paste for a token)
47
+ bikeshed logout sign out (revokes a token from browser sign-in)
53
48
  bikeshed list documents you can read
54
49
  bikeshed publish <file> create a new document
55
50
  bikeshed update <id> <file> publish a new version
@@ -116,10 +111,6 @@ Options
116
111
 
117
112
  Environment
118
113
  BIKESHED_ORIGIN, BIKESHED_TOKEN, BIKESHED_AGENT
119
-
120
- This is the end-user tool. The scripts in scripts/ are the OPERATOR tools: they
121
- use admin credentials, talk to the database directly, and can tear a deployment
122
- down. They are not the same thing.
123
114
  `;
124
115
 
125
116
  function fail(message) {
@@ -275,7 +266,9 @@ async function browserLogin(flags) {
275
266
  });
276
267
  // Checked before it is stored, the same as a pasted one.
277
268
  await whoami(where, token);
278
- writeConfig({ ...readConfig(), origin: 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" });
279
272
 
280
273
  // The environment outranks the file (resolveAuth), so a variable left over
281
274
  // from an earlier session would quietly keep being used instead.
@@ -313,13 +306,78 @@ async function pasteLogin(flags) {
313
306
  // Verified before it is stored, so a typo fails here rather than on the
314
307
  // next command with a confusing message.
315
308
  await whoami(where, token);
316
- writeConfig({ ...readConfig(), origin: where, token });
309
+ writeConfig({ ...readConfig(), origin: where, token, via: "paste" });
317
310
  console.log(`\nSaved to ${CONFIG_PATH} (readable only by you).`);
318
311
  } finally {
319
312
  rl.close();
320
313
  }
321
314
  }
322
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
+
323
381
  async function list(flags) {
324
382
  const { origin, token } = resolveAuth(flags);
325
383
  const page = (limit, cursor) => {
@@ -737,6 +795,7 @@ async function watchFile(file, run) {
737
795
 
738
796
  const COMMANDS = {
739
797
  login,
798
+ logout,
740
799
  list,
741
800
  publish,
742
801
  update,
@@ -755,7 +814,7 @@ const COMMANDS = {
755
814
  };
756
815
 
757
816
  /** Commands that reach a deployment without authenticating as anybody. */
758
- const NO_TOKEN = new Set(["login", "open", "skills"]);
817
+ const NO_TOKEN = new Set(["login", "logout", "open", "skills"]);
759
818
 
760
819
  async function main() {
761
820
  const { values, positionals } = parseArgs({