bikeshed-now 0.9.0 → 0.11.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.11.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,14 @@
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
+ /**
8
+ * This package's version, from the package.json npm ships beside `src/`. Read
9
+ * rather than written here, so `--version` and the client header cannot drift
10
+ * from what was installed.
11
+ */
12
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
+
14
+ export const CLIENT_NAME = `bikeshed-cli/${VERSION}`;
8
15
 
9
16
  /**
10
17
  * Three surfaces, and the split is deliberate rather than untidy.
@@ -225,6 +232,40 @@ export async function exchangeCode(tokenEndpoint, { code, verifier, redirectUri,
225
232
  }
226
233
 
227
234
  /** Check that a token works, and say who it belongs to. */
235
+ /**
236
+ * Revoke the token being presented, and only it (SPEC §23).
237
+ *
238
+ * A 401 from /tokens means one of two things that need different advice: the
239
+ * token had already stopped working, or the deployment is older than a token
240
+ * revoking itself and still takes only a browser session there. One call with
241
+ * the same token to /mcp tells them apart. Returns "revoked", "invalid" or
242
+ * "unsupported"; a deployment that cannot be reached throws.
243
+ */
244
+ export async function revokeOwnToken(origin, token) {
245
+ const res = await fetch(`${origin}/tokens`, {
246
+ method: "POST",
247
+ headers: headers(token, { "Content-Type": "application/json" }),
248
+ body: JSON.stringify({ op: "revoke" }),
249
+ });
250
+ if (res.ok) return "revoked";
251
+ if (res.status !== 401) {
252
+ throw new CliError(await readError(res, `the server returned ${res.status}`));
253
+ }
254
+ const probe = await fetch(`${origin}/mcp`, {
255
+ method: "POST",
256
+ headers: headers(token, { "Content-Type": "application/json" }),
257
+ body: JSON.stringify({
258
+ jsonrpc: "2.0",
259
+ id: 1,
260
+ method: "tools/call",
261
+ params: { name: "list_documents", arguments: { limit: 1 } },
262
+ }),
263
+ });
264
+ if (probe.status === 401) return "invalid";
265
+ if (probe.ok) return "unsupported";
266
+ throw new CliError(`the server returned ${probe.status}`);
267
+ }
268
+
228
269
  export async function whoami(origin, token) {
229
270
  // list_documents is the cheapest authenticated call, and a token that can
230
271
  // list is a token that works.
package/src/index.mjs CHANGED
@@ -10,12 +10,14 @@ import { createInterface } from "node:readline/promises";
10
10
  import { CONFIG_PATH, readConfig, resolveAuth, writeConfig } from "./config.mjs";
11
11
  import {
12
12
  CliError,
13
+ VERSION,
13
14
  callTool,
14
15
  exchangeCode,
15
16
  fetchSkill,
16
17
  fetchSource,
17
18
  listSkills,
18
19
  printable,
20
+ revokeOwnToken,
19
21
  uploadFile,
20
22
  whoami,
21
23
  } from "./client.mjs";
@@ -33,23 +35,17 @@ import {
33
35
  } from "./login.mjs";
34
36
 
35
37
  /**
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.
38
+ * A person's own client: their personal access token, over HTTPS, able to do
39
+ * exactly what they can do in the browser and nothing more.
45
40
  *
46
41
  * Every command takes `--json`, so it composes inside a script or an agent's
47
42
  * shell without anybody parsing human-readable output.
48
43
  */
49
44
 
50
- const USAGE = `bikeshed — publish and revise documents
45
+ const USAGE = `bikeshed ${VERSION} — publish and revise documents
51
46
 
52
47
  bikeshed login sign in through your browser (--paste for a token)
48
+ bikeshed logout sign out (revokes a token from browser sign-in)
53
49
  bikeshed list documents you can read
54
50
  bikeshed publish <file> create a new document
55
51
  bikeshed update <id> <file> publish a new version
@@ -97,7 +93,8 @@ Options
97
93
  --title <text> title for publish or fork (fork default: the original's)
98
94
  --id <id> with publish: the address, /d/<id> (default: from the title)
99
95
  --folder <id> destination folder for publish or fork
100
- --version <v> with fork: the version to copy (default: current)
96
+ --version <v> with fork: the version to copy (default: current);
97
+ on its own, which version of bikeshed this is
101
98
  --parent <id> parent folder for mkdir
102
99
  --limit <n> with list: rows per page (default 20, max 50)
103
100
  --cursor <id> with list: the page after the one that printed it
@@ -116,10 +113,6 @@ Options
116
113
 
117
114
  Environment
118
115
  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
116
  `;
124
117
 
125
118
  function fail(message) {
@@ -275,7 +268,9 @@ async function browserLogin(flags) {
275
268
  });
276
269
  // Checked before it is stored, the same as a pasted one.
277
270
  await whoami(where, token);
278
- writeConfig({ ...readConfig(), origin: where, token });
271
+ // `via` is what lets `logout` revoke this token: one made by browser sign-in
272
+ // belongs to this computer alone, where a pasted one may be in CI as well.
273
+ writeConfig({ ...readConfig(), origin: where, token, via: "browser" });
279
274
 
280
275
  // The environment outranks the file (resolveAuth), so a variable left over
281
276
  // from an earlier session would quietly keep being used instead.
@@ -313,13 +308,78 @@ async function pasteLogin(flags) {
313
308
  // Verified before it is stored, so a typo fails here rather than on the
314
309
  // next command with a confusing message.
315
310
  await whoami(where, token);
316
- writeConfig({ ...readConfig(), origin: where, token });
311
+ writeConfig({ ...readConfig(), origin: where, token, via: "paste" });
317
312
  console.log(`\nSaved to ${CONFIG_PATH} (readable only by you).`);
318
313
  } finally {
319
314
  rl.close();
320
315
  }
321
316
  }
322
317
 
318
+ /**
319
+ * Sign this computer out.
320
+ *
321
+ * Only the SAVED sign-in, never BIKESHED_TOKEN or --token: those belong to a
322
+ * shell or a job, and revoking one would sign out something nobody meant to
323
+ * touch. A token from browser sign-in exists for this computer alone, so it is
324
+ * revoked on the deployment as well as forgotten here. A pasted one may be in
325
+ * CI too, so it is only forgotten, as is one saved before `login` recorded
326
+ * which kind it was (SPEC §23).
327
+ *
328
+ * The token is forgotten whatever the deployment says, and when it cannot be
329
+ * reached at all: after `logout`, this computer is signed out. `revoked` in
330
+ * --json means the deployment confirmed the token no longer works.
331
+ */
332
+ async function logout(flags) {
333
+ const config = readConfig();
334
+ const origin = String(config.origin || resolveAuth().origin).replace(/\/+$/, "");
335
+ const shadowed = () => {
336
+ if (process.env.BIKESHED_TOKEN) {
337
+ console.error("BIKESHED_TOKEN is set in this shell, so commands here still use it. Unset it to sign out here too.");
338
+ }
339
+ };
340
+
341
+ if (!config.token) {
342
+ out(flags.json, { origin, revoked: false, forgot: false }, () => console.log("Not signed in on this computer."));
343
+ shadowed();
344
+ return;
345
+ }
346
+
347
+ let outcome = "kept";
348
+ let why = "You pasted it in, so something else may use it too.";
349
+ if (config.via === "browser") {
350
+ try {
351
+ outcome = await revokeOwnToken(origin, config.token);
352
+ why = `${origin} is too old to revoke a token from the command line.`;
353
+ } catch (e) {
354
+ outcome = "failed";
355
+ why =
356
+ e instanceof CliError
357
+ ? `${origin} did not revoke it (${printable(e.message, 80)}).`
358
+ : `Could not reach ${origin} to revoke it (${printable(e.cause?.code ?? e.message, 80)}).`;
359
+ }
360
+ }
361
+
362
+ const kept = { ...config, origin };
363
+ delete kept.token;
364
+ delete kept.via;
365
+ writeConfig(kept);
366
+
367
+ const revoked = outcome === "revoked" || outcome === "invalid";
368
+ out(flags.json, { origin, revoked, forgot: true }, () =>
369
+ console.log(
370
+ outcome === "revoked"
371
+ ? `Signed out. ${origin} revoked the token, and it is gone from this computer.`
372
+ : outcome === "invalid"
373
+ ? `Signed out. The token had already stopped working on ${origin}, and it is gone from this computer.`
374
+ : "Signed out. The token is gone from this computer.",
375
+ ),
376
+ );
377
+ if (!revoked) {
378
+ console.error(`${why} It still works until you revoke it at ${origin}/account, under Access tokens.`);
379
+ }
380
+ shadowed();
381
+ }
382
+
323
383
  async function list(flags) {
324
384
  const { origin, token } = resolveAuth(flags);
325
385
  const page = (limit, cursor) => {
@@ -737,6 +797,7 @@ async function watchFile(file, run) {
737
797
 
738
798
  const COMMANDS = {
739
799
  login,
800
+ logout,
740
801
  list,
741
802
  publish,
742
803
  update,
@@ -755,7 +816,7 @@ const COMMANDS = {
755
816
  };
756
817
 
757
818
  /** Commands that reach a deployment without authenticating as anybody. */
758
- const NO_TOKEN = new Set(["login", "open", "skills"]);
819
+ const NO_TOKEN = new Set(["login", "logout", "open", "skills"]);
759
820
 
760
821
  async function main() {
761
822
  const { values, positionals } = parseArgs({
@@ -788,6 +849,14 @@ async function main() {
788
849
  });
789
850
 
790
851
  const [name, ...rest] = positionals;
852
+ // On its own, --version is about this program; after `fork` it names the
853
+ // version of a document to copy, which is why it is not checked first.
854
+ // Declared as taking a value for fork's sake, it swallows a `--json` that
855
+ // follows it, so that value is read back as the flag it was.
856
+ if (!name && !values.help && (values.version !== undefined || values.v)) {
857
+ out(values.json || values.version === "--json", { version: VERSION }, () => console.log(`bikeshed ${VERSION}`));
858
+ return;
859
+ }
791
860
  if (values.help || !name) {
792
861
  console.log(USAGE);
793
862
  return;