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 +25 -7
- package/package.json +2 -2
- package/src/client.mjs +94 -1
- package/src/index.mjs +257 -20
- package/src/login.mjs +298 -0
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 #
|
|
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.
|
|
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,
|
|
31
|
-
|
|
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.
|
|
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": ">=
|
|
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.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
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
+
}
|