bikeshed-now 0.5.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 +25 -7
- package/package.json +2 -2
- package/src/client.mjs +60 -1
- package/src/index.mjs +232 -5
- package/src/login.mjs +298 -0
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 #
|
|
13
|
+
bikeshed login # signs you in through your browser
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,9 +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,
|
|
31
|
-
|
|
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
|
|
44
|
+
|
|
45
|
+
# Given "Can suggest" rather than "Can edit"? Propose the change instead, for a
|
|
46
|
+
# person to accept or reject.
|
|
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?"
|
|
33
51
|
```
|
|
34
52
|
|
|
35
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.
|
|
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": ">=
|
|
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.
|
|
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
|
|
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,7 +57,13 @@ 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
|
|
63
|
+
bikeshed suggest <id> <text> --with <new text>
|
|
64
|
+
propose new text for a passage, for a
|
|
65
|
+
person to accept or reject (--delete to
|
|
66
|
+
propose removing it)
|
|
47
67
|
bikeshed open <id> open it in a browser
|
|
48
68
|
|
|
49
69
|
Where a command takes <id> it also takes the link you were sent. An unlisted
|
|
@@ -82,7 +102,14 @@ Options
|
|
|
82
102
|
--limit <n> with list: rows per page (default 20, max 50)
|
|
83
103
|
--cursor <id> with list: the page after the one that printed it
|
|
84
104
|
--all with list: every page; with comments: resolved too
|
|
105
|
+
--with <text> with suggest: what the passage should say instead
|
|
106
|
+
--delete with suggest: propose deleting the passage
|
|
107
|
+
--note <text> with suggest: why, for whoever decides
|
|
108
|
+
--prefix <text> with suggest: words just before a passage that repeats
|
|
109
|
+
--suffix <text> with suggest: words just after it
|
|
85
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
|
|
86
113
|
--origin <url> the deployment (default: the one you logged in to)
|
|
87
114
|
--token <token> override the stored token
|
|
88
115
|
--help this
|
|
@@ -137,7 +164,140 @@ function isArchive(path, bytes) {
|
|
|
137
164
|
|
|
138
165
|
// --- Commands --------------------------------------------------------------
|
|
139
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
|
+
*/
|
|
140
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) {
|
|
141
301
|
const { origin } = resolveAuth(flags);
|
|
142
302
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
143
303
|
try {
|
|
@@ -303,6 +463,12 @@ async function comments(flags, [ref]) {
|
|
|
303
463
|
// which passage to change without opening the document.
|
|
304
464
|
console.log(`\n── ${thread.threadId}${thread.section ? ` (${thread.section})` : ""}`);
|
|
305
465
|
if (thread.quote) console.log(` “${thread.quote}”`);
|
|
466
|
+
// A suggested edit says what the passage would become, and whether a
|
|
467
|
+
// person has decided it yet (SPEC §22).
|
|
468
|
+
if (thread.suggestion) {
|
|
469
|
+
const change = thread.suggestion.replacement === "" ? "(delete it)" : `“${thread.suggestion.replacement}”`;
|
|
470
|
+
console.log(` → ${change} [${thread.suggestion.state}]`);
|
|
471
|
+
}
|
|
306
472
|
for (const comment of thread.comments) {
|
|
307
473
|
const via = comment.via && comment.via !== "web" ? ` [via ${comment.via}]` : "";
|
|
308
474
|
console.log(` ${comment.author}${via}: ${comment.body}`);
|
|
@@ -312,6 +478,29 @@ async function comments(flags, [ref]) {
|
|
|
312
478
|
});
|
|
313
479
|
}
|
|
314
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
|
+
|
|
315
504
|
async function reply(flags, [ref, threadId, ...rest]) {
|
|
316
505
|
const body = rest.join(" ");
|
|
317
506
|
if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
|
|
@@ -324,6 +513,34 @@ async function reply(flags, [ref, threadId, ...rest]) {
|
|
|
324
513
|
out(flags.json, result, () => console.log(`replied in ${threadId}`));
|
|
325
514
|
}
|
|
326
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Propose new text for one passage of a document's current version. The
|
|
518
|
+
* passage is quoted as a reader sees it, which is how `comments` prints it, so
|
|
519
|
+
* a quote can go straight from one command to the other.
|
|
520
|
+
*/
|
|
521
|
+
async function suggest(flags, [ref, ...rest]) {
|
|
522
|
+
const exact = rest.join(" ");
|
|
523
|
+
const usage = "usage: bikeshed suggest <id> <text> (--with <new text> | --delete) [--note <why>]";
|
|
524
|
+
if (!ref || !exact) fail(usage);
|
|
525
|
+
const replacing = typeof flags.with === "string";
|
|
526
|
+
if (replacing === Boolean(flags.delete)) fail(usage);
|
|
527
|
+
// An empty --with is what an unset shell variable sends; deleting a passage
|
|
528
|
+
// has to be asked for by name.
|
|
529
|
+
if (replacing && flags.with === "") fail("an empty --with would delete the passage; say --delete if you mean that");
|
|
530
|
+
const { origin, token } = resolveAuth(flags);
|
|
531
|
+
const result = await callTool(origin, token, "suggest_edit", {
|
|
532
|
+
...docArgs(ref),
|
|
533
|
+
exact,
|
|
534
|
+
replacement: replacing ? flags.with : "",
|
|
535
|
+
...(typeof flags.note === "string" ? { note: flags.note } : {}),
|
|
536
|
+
...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
|
|
537
|
+
...(typeof flags.suffix === "string" ? { suffix: flags.suffix } : {}),
|
|
538
|
+
});
|
|
539
|
+
out(flags.json, result, () =>
|
|
540
|
+
console.log(`suggested in ${result.threadId}; a person who can approve will accept or reject it`),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
327
544
|
async function folders(flags) {
|
|
328
545
|
const { origin, token } = resolveAuth(flags);
|
|
329
546
|
const result = await callTool(origin, token, "list_folders", {});
|
|
@@ -489,10 +706,10 @@ async function open(flags, [id]) {
|
|
|
489
706
|
if (!id) fail("usage: bikeshed open <id>");
|
|
490
707
|
const { origin } = resolveAuth(flags);
|
|
491
708
|
const url = `${origin}/d/${id}`;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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);
|
|
496
713
|
console.log(url);
|
|
497
714
|
}
|
|
498
715
|
|
|
@@ -527,7 +744,9 @@ const COMMANDS = {
|
|
|
527
744
|
lineage,
|
|
528
745
|
get,
|
|
529
746
|
comments,
|
|
747
|
+
comment,
|
|
530
748
|
reply,
|
|
749
|
+
suggest,
|
|
531
750
|
open,
|
|
532
751
|
folders,
|
|
533
752
|
mkdir,
|
|
@@ -557,6 +776,14 @@ async function main() {
|
|
|
557
776
|
token: { type: "string" },
|
|
558
777
|
limit: { type: "string" },
|
|
559
778
|
cursor: { type: "string" },
|
|
779
|
+
with: { type: "string" },
|
|
780
|
+
delete: { type: "boolean", default: false },
|
|
781
|
+
paste: { type: "boolean", default: false },
|
|
782
|
+
"no-browser": { type: "boolean", default: false },
|
|
783
|
+
note: { type: "string" },
|
|
784
|
+
body: { type: "string" },
|
|
785
|
+
prefix: { type: "string" },
|
|
786
|
+
suffix: { type: "string" },
|
|
560
787
|
},
|
|
561
788
|
});
|
|
562
789
|
|
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
|
+
}
|