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 +4 -0
- package/package.json +1 -1
- package/src/client.mjs +42 -1
- package/src/index.mjs +87 -18
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
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
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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;
|