@plotio/cli 0.1.0 → 0.2.1
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 +36 -13
- package/dist/plot.js +499 -139
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,22 +26,46 @@ as a hash:
|
|
|
26
26
|
a one-time `temp_code_…` (ten minutes, single use).
|
|
27
27
|
`plot open temp_code_… --agent "Claude Code"` redeems it for a token that
|
|
28
28
|
reads that one page (24 hours from the last read, a week at most).
|
|
29
|
-
- **A workspace.** `plot login --agent "Claude Code"`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
- **A workspace.** `plot login --agent "Claude Code"` signs in the way Claude
|
|
30
|
+
Code does: OAuth authorization code with PKCE (S256), client id `plot-cli`,
|
|
31
|
+
against the authorization server the API names in
|
|
32
|
+
`/.well-known/oauth-authorization-server`. So `--api` at a local or preview
|
|
33
|
+
deployment signs in on that deployment's own web app, never production's.
|
|
34
|
+
The code is exchanged at the token endpoint, `/v1/me` names the workspace,
|
|
35
|
+
and the token is stored as that API's workspace grant.
|
|
36
|
+
|
|
37
|
+
One verifier and state make two authorize URLs that differ only in
|
|
38
|
+
`redirect_uri`, and the server binds a code to the one it was approved with:
|
|
39
|
+
a loopback one (`http://127.0.0.1:<port>/callback`) and the out-of-band
|
|
40
|
+
`urn:ietf:wg:oauth:2.0:oob`, for which the consent page shows the code
|
|
41
|
+
instead of redirecting. The loopback URL is only ever opened, never printed,
|
|
42
|
+
because a browser on any other machine would be redirected into a dead port.
|
|
43
|
+
|
|
44
|
+
- **On your own machine.** The browser opens on the loopback URL; approve
|
|
45
|
+
and it calls the terminal back by itself. Nothing to paste.
|
|
46
|
+
- **Browser elsewhere (SSH, another computer).** The terminal prints the
|
|
47
|
+
out-of-band URL under "A browser tab should have opened. If it did not, or
|
|
48
|
+
it is on another machine, open this URL:". Approve there, and paste the
|
|
49
|
+
code the page shows at `Paste code here if prompted:`. Whichever arrives
|
|
50
|
+
first wins, within ten minutes. `--no-browser` prints only that URL and
|
|
51
|
+
does not listen at all.
|
|
52
|
+
- **An agent or a sandbox (no TTY).** Nothing listens and nothing waits.
|
|
53
|
+
`plot login` prints the out-of-band URL and
|
|
54
|
+
`After approving, run: plot login --code <code>`, saves a pending login
|
|
55
|
+
(API base, verifier, state, agent name, time) in
|
|
56
|
+
`$XDG_CONFIG_HOME/plot/pending-login.json` at `0600`, and exits 0. The agent
|
|
57
|
+
hands the URL to its person, who approves and sends the code back;
|
|
58
|
+
`plot login --code <code>` exchanges it, stores the grant, deletes the
|
|
59
|
+
pending file and prints `Connected to <workspace>`. A pending login lasts
|
|
60
|
+
ten minutes, and `--code` works at a terminal too.
|
|
38
61
|
|
|
39
62
|
## Commands
|
|
40
63
|
|
|
41
64
|
| Command | Does |
|
|
42
65
|
| ------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
43
66
|
| `plot open <temp_code_…> [--agent <name>]` | Redeem a handoff code and store the page token |
|
|
44
|
-
| `plot login [--agent] [--
|
|
67
|
+
| `plot login [--agent] [--no-browser]` | Connect a workspace: sign in and approve in the browser |
|
|
68
|
+
| `plot login --code <code>` | Finish a login started without a terminal, with the code the browser showed |
|
|
45
69
|
| `plot logout [<page>] [--all]` | Revoke a credential on the server, then forget it |
|
|
46
70
|
| `plot whoami [<page>] [--json]` | What the credential acts for, its scope and expiry |
|
|
47
71
|
| `plot pages [--limit <n>] [--next <c>] [--json]` | Pages the workspace grant can read, newest first |
|
|
@@ -114,7 +138,6 @@ every other server, and refuses a file whose top level is not a JSON object.
|
|
|
114
138
|
| ----------------- | ------------------------------------------------------------------------- |
|
|
115
139
|
| `PLOT_API_URL` | API base. Default `https://agents.plot.io`; a deployment's `.convex.site` |
|
|
116
140
|
| | host for local dev and previews. `--api` wins over it. |
|
|
117
|
-
| `PLOT_SITE_URL` | Where `login` sends the browser. Default: the site the API names. |
|
|
118
141
|
| `PLOT_TOKEN` | Use this token instead of the store (a sandbox or CI job). |
|
|
119
142
|
| `PLOT_AGENT_NAME` | Default `--agent`. Inside Claude Code the default is "Claude Code". |
|
|
120
143
|
| `XDG_CONFIG_HOME` | Where the credential store lives (below). |
|
|
@@ -166,6 +189,6 @@ pnpm --filter @plotio/cli build # dist/plot.js, one bundled file
|
|
|
166
189
|
`pnpm publish` from a clean tree still ship `dist/plot.js`.
|
|
167
190
|
|
|
168
191
|
All HTTP lives in `src/api.ts`. The build bundles with esbuild rather than
|
|
169
|
-
emitting with `tsc`, because `@plot/shared` (token shapes
|
|
170
|
-
|
|
192
|
+
emitting with `tsc`, because `@plot/shared` (token shapes) is a private
|
|
193
|
+
workspace package: the bundle inlines it, so the
|
|
171
194
|
published package has no dependencies at all.
|
package/dist/plot.js
CHANGED
|
@@ -21,11 +21,10 @@ Run "plot --help" for usage.`, 2);
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
// src/version.ts
|
|
24
|
-
var VERSION = "0.1
|
|
24
|
+
var VERSION = "0.2.1";
|
|
25
25
|
|
|
26
26
|
// src/api.ts
|
|
27
27
|
var DEFAULT_API_URL = "https://agents.plot.io";
|
|
28
|
-
var DEFAULT_SITE_URL = "https://www.plot.io";
|
|
29
28
|
var ApiError = class extends Error {
|
|
30
29
|
status;
|
|
31
30
|
/** The server's machine reason (`expired`, `no_access`, …), or `network`/`http`. */
|
|
@@ -55,10 +54,6 @@ function resolveApiBase(flag, env) {
|
|
|
55
54
|
"API URL"
|
|
56
55
|
);
|
|
57
56
|
}
|
|
58
|
-
function explicitSiteBase(flag, env) {
|
|
59
|
-
const raw = flag || env.PLOT_SITE_URL?.trim();
|
|
60
|
-
return raw ? normalizeBase(raw, "Site URL") : void 0;
|
|
61
|
-
}
|
|
62
57
|
var UPDATE_LEVELS = [
|
|
63
58
|
"every",
|
|
64
59
|
"substantial",
|
|
@@ -126,24 +121,80 @@ function createApi(opts) {
|
|
|
126
121
|
/**
|
|
127
122
|
* GET /.well-known/oauth-authorization-server, unauthenticated. Its
|
|
128
123
|
* `authorization_endpoint` is on the web app that deployment belongs to,
|
|
129
|
-
* which is how `login` finds the site for
|
|
130
|
-
*
|
|
124
|
+
* which is how `login` finds the site for any `--api`; its
|
|
125
|
+
* `token_endpoint` is where the code is exchanged.
|
|
131
126
|
*/
|
|
132
127
|
async authorizationServer() {
|
|
133
128
|
return json(
|
|
134
129
|
await request("GET", "/.well-known/oauth-authorization-server")
|
|
135
130
|
);
|
|
136
131
|
},
|
|
132
|
+
/**
|
|
133
|
+
* POST <token_endpoint>: exchange an authorization code for a workspace
|
|
134
|
+
* token. A form body, as OAuth has it, and OAuth-shaped errors
|
|
135
|
+
* (`{ error, error_description }`), whose description becomes the
|
|
136
|
+
* message. Never retried: a code works once.
|
|
137
|
+
*/
|
|
138
|
+
async exchangeCode(tokenEndpoint, form) {
|
|
139
|
+
const url = new URL(tokenEndpoint);
|
|
140
|
+
let response;
|
|
141
|
+
try {
|
|
142
|
+
response = await fetchFn(url, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
Accept: "application/json",
|
|
146
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
147
|
+
"User-Agent": `plot-cli/${VERSION}`
|
|
148
|
+
},
|
|
149
|
+
body: new URLSearchParams({
|
|
150
|
+
grant_type: "authorization_code",
|
|
151
|
+
...form
|
|
152
|
+
}).toString()
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw new ApiError(
|
|
156
|
+
0,
|
|
157
|
+
"network",
|
|
158
|
+
`Could not reach ${url.origin}: ${err.message}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const text = await response.text().catch(() => "");
|
|
162
|
+
let body;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(text);
|
|
165
|
+
if (parsed && typeof parsed === "object") {
|
|
166
|
+
body = parsed;
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const code = typeof body?.error === "string" ? body.error : "http";
|
|
172
|
+
const description = typeof body?.error_description === "string" ? body.error_description : void 0;
|
|
173
|
+
throw new ApiError(
|
|
174
|
+
response.status,
|
|
175
|
+
code,
|
|
176
|
+
description ?? `The token request failed with HTTP ${response.status} (${code}).`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (typeof body?.access_token !== "string" || !body.access_token) {
|
|
180
|
+
throw new ApiError(
|
|
181
|
+
response.status,
|
|
182
|
+
"bad_response",
|
|
183
|
+
`Expected an access_token from ${url.origin}, got: ${text.slice(0, 200)}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return body;
|
|
187
|
+
},
|
|
137
188
|
/** DELETE /v1/me: revoke the presented credential. */
|
|
138
189
|
async revoke(token) {
|
|
139
190
|
await request("DELETE", "/v1/me", { token });
|
|
140
191
|
},
|
|
141
192
|
/** GET /v1/pages: pages the workspace grant can read, newest first. */
|
|
142
|
-
async pages(token,
|
|
193
|
+
async pages(token, page2 = {}) {
|
|
143
194
|
return json(
|
|
144
195
|
await request("GET", "/v1/pages", {
|
|
145
196
|
token,
|
|
146
|
-
query: { limit:
|
|
197
|
+
query: { limit: page2.limit, cursor: page2.next }
|
|
147
198
|
})
|
|
148
199
|
);
|
|
149
200
|
},
|
|
@@ -192,11 +243,11 @@ function createApi(opts) {
|
|
|
192
243
|
);
|
|
193
244
|
},
|
|
194
245
|
/** GET /v1/search?q=. */
|
|
195
|
-
async search(token, q,
|
|
246
|
+
async search(token, q, page2 = {}) {
|
|
196
247
|
return json(
|
|
197
248
|
await request("GET", "/v1/search", {
|
|
198
249
|
token,
|
|
199
|
-
query: { q, limit:
|
|
250
|
+
query: { q, limit: page2.limit, cursor: page2.next }
|
|
200
251
|
})
|
|
201
252
|
);
|
|
202
253
|
},
|
|
@@ -270,28 +321,10 @@ var LOOSE = new RegExp("^(?:\\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\\p{
|
|
|
270
321
|
|
|
271
322
|
// ../shared/src/agentTokens.ts
|
|
272
323
|
var AGENT_CODE_PREFIX = "temp_code_";
|
|
273
|
-
var AGENT_SESSION_PREFIX = "plot_agent_sk_";
|
|
274
324
|
var AGENT_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
275
325
|
var AGENT_JOIN_TOKEN_TTL_MS = 2 * 60 * 1e3;
|
|
276
326
|
var AGENT_SESSION_IDLE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
277
327
|
var AGENT_SESSION_MAX_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
278
|
-
function base64Url(bytes) {
|
|
279
|
-
let binary = "";
|
|
280
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
281
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
282
|
-
}
|
|
283
|
-
function mintAgentToken(prefix) {
|
|
284
|
-
const bytes = new Uint8Array(32);
|
|
285
|
-
crypto.getRandomValues(bytes);
|
|
286
|
-
return prefix + base64Url(bytes);
|
|
287
|
-
}
|
|
288
|
-
async function hashAgentToken(token) {
|
|
289
|
-
const digest = await crypto.subtle.digest(
|
|
290
|
-
"SHA-256",
|
|
291
|
-
new TextEncoder().encode(token)
|
|
292
|
-
);
|
|
293
|
-
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
294
|
-
}
|
|
295
328
|
function looksLikeToken(value, prefix) {
|
|
296
329
|
return typeof value === "string" && value.startsWith(prefix) && value.length > prefix.length && value.length <= prefix.length + 64;
|
|
297
330
|
}
|
|
@@ -364,7 +397,8 @@ var USAGE = `plot ${VERSION}: Plot pages for agents and scripts
|
|
|
364
397
|
Usage: plot <command> [options]
|
|
365
398
|
|
|
366
399
|
open <temp_code_\u2026> [--agent <name>] Redeem a handoff code; stores the page token
|
|
367
|
-
login [--agent <name>] [--
|
|
400
|
+
login [--agent <name>] [--no-browser] Connect a workspace (sign in in the browser)
|
|
401
|
+
login --code <code> Finish a login with the code the browser showed
|
|
368
402
|
logout [<page>] [--all] Revoke a credential and forget it
|
|
369
403
|
whoami [<page>] What the stored credential can reach
|
|
370
404
|
pages [--limit <n>] [--next <c>] Pages you can read (workspace login)
|
|
@@ -390,8 +424,11 @@ date as people talk. You reach a page with a token someone gave you.
|
|
|
390
424
|
|
|
391
425
|
Get access (once; the token is stored, so later shells need nothing):
|
|
392
426
|
plot open temp_code_\u2026 --agent "<your name>" A code a person copied from a page
|
|
393
|
-
plot login --agent "<your name>" Or a whole workspace
|
|
394
|
-
|
|
427
|
+
plot login --agent "<your name>" Or a whole workspace: it prints a URL
|
|
428
|
+
and exits. Give the person the URL;
|
|
429
|
+
they sign in, approve, and send you
|
|
430
|
+
the code the page shows. Then:
|
|
431
|
+
plot login --code <code> Finishes it (within ten minutes)
|
|
395
432
|
|
|
396
433
|
Read:
|
|
397
434
|
plot read <page> The page as Markdown: transcript, then Topics.
|
|
@@ -425,14 +462,25 @@ Redeems a one-time handoff code (from a page's "Copy agent instructions")
|
|
|
425
462
|
for a page token, and stores it. A code works once and lasts ten minutes.
|
|
426
463
|
--agent names you in the page's share dialog (default: PLOT_AGENT_NAME,
|
|
427
464
|
"Claude Code" inside Claude Code, else "Plot CLI").`,
|
|
428
|
-
login: `plot login [--agent <name>] [--
|
|
465
|
+
login: `plot login [--agent <name>] [--no-browser]
|
|
466
|
+
plot login --code <code>
|
|
467
|
+
|
|
468
|
+
Connects a whole workspace with OAuth (authorization code and PKCE), signing
|
|
469
|
+
in on the web app the API names in its OAuth metadata (so --api at a local
|
|
470
|
+
or preview deployment signs in on that deployment's own site).
|
|
429
471
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
472
|
+
In a terminal it opens this machine's browser, which hands the code back by
|
|
473
|
+
itself, and prints a URL for a browser anywhere else (over SSH, another
|
|
474
|
+
computer): approving there shows a code to paste at "Paste code here if
|
|
475
|
+
prompted:". It waits up to ten minutes.
|
|
476
|
+
|
|
477
|
+
Without a terminal (an agent's shell, a pipe) it prints the URL and exits.
|
|
478
|
+
Whoever approves gets a code; "plot login --code <code>" finishes the login
|
|
479
|
+
within ten minutes. --code works in a terminal too.
|
|
480
|
+
|
|
481
|
+
--no-browser prints the URL without opening anything.
|
|
482
|
+
--agent names the connection in Settings (default: PLOT_AGENT_NAME,
|
|
483
|
+
"Claude Code" inside Claude Code, else "Plot CLI").`,
|
|
436
484
|
logout: `plot logout [<page>] [--all]
|
|
437
485
|
|
|
438
486
|
Revokes a credential (DELETE /v1/me) and forgets it. With no argument, the
|
|
@@ -500,55 +548,131 @@ there, so its block always holds the token.
|
|
|
500
548
|
};
|
|
501
549
|
|
|
502
550
|
// src/login.ts
|
|
503
|
-
|
|
551
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
552
|
+
var CLIENT_ID = "plot-cli";
|
|
553
|
+
var OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
|
|
504
554
|
var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const url = new URL(`${site}/connect`);
|
|
510
|
-
url.searchParams.set("hash", hash);
|
|
511
|
-
url.searchParams.set("agent", agent);
|
|
512
|
-
url.searchParams.set("fp", fingerprint(hash));
|
|
513
|
-
return url.toString();
|
|
555
|
+
var PASTE_PROMPT = "Paste code here if prompted: ";
|
|
556
|
+
var PENDING_LOGIN_MS = 10 * 60 * 1e3;
|
|
557
|
+
function newVerifier() {
|
|
558
|
+
return randomBytes(32).toString("base64url");
|
|
514
559
|
}
|
|
515
|
-
|
|
516
|
-
|
|
560
|
+
function newState() {
|
|
561
|
+
return randomBytes(16).toString("base64url");
|
|
562
|
+
}
|
|
563
|
+
function challengeFor(verifier) {
|
|
564
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
565
|
+
}
|
|
566
|
+
async function discoverEndpoints(api, apiBase) {
|
|
517
567
|
let reason;
|
|
518
568
|
try {
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
569
|
+
const meta = await api.authorizationServer();
|
|
570
|
+
const authorizationEndpoint = httpUrl(meta.authorization_endpoint);
|
|
571
|
+
const tokenEndpoint = httpUrl(meta.token_endpoint);
|
|
572
|
+
if (authorizationEndpoint && tokenEndpoint) {
|
|
573
|
+
return { authorizationEndpoint, tokenEndpoint };
|
|
525
574
|
}
|
|
526
|
-
reason =
|
|
575
|
+
reason = `its OAuth metadata names no ${authorizationEndpoint ? "token" : "authorization"} endpoint`;
|
|
527
576
|
} catch (err) {
|
|
528
577
|
reason = err.message;
|
|
529
578
|
}
|
|
530
|
-
if (opts.apiBase === DEFAULT_API_URL) return DEFAULT_SITE_URL;
|
|
531
579
|
throw new CliError(
|
|
532
|
-
`Could not
|
|
580
|
+
`Could not start a login with ${apiBase}: ${reason}. Check --api (or PLOT_API_URL).`
|
|
533
581
|
);
|
|
534
582
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
583
|
+
function httpUrl(value) {
|
|
584
|
+
if (typeof value !== "string") return void 0;
|
|
585
|
+
try {
|
|
586
|
+
const url = new URL(value);
|
|
587
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : void 0;
|
|
588
|
+
} catch {
|
|
589
|
+
return void 0;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function buildAuthorizeUrl(opts) {
|
|
593
|
+
const url = new URL(opts.authorizationEndpoint);
|
|
594
|
+
url.searchParams.set("response_type", "code");
|
|
595
|
+
url.searchParams.set("client_id", CLIENT_ID);
|
|
596
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
597
|
+
url.searchParams.set("code_challenge", opts.challenge);
|
|
598
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
599
|
+
url.searchParams.set("state", opts.state);
|
|
600
|
+
url.searchParams.set("agent", opts.agent);
|
|
601
|
+
return url.toString();
|
|
602
|
+
}
|
|
603
|
+
function codeFromPaste(line, state) {
|
|
604
|
+
const text = line.trim();
|
|
605
|
+
if (/^https?:\/\//i.test(text)) {
|
|
606
|
+
const url = new URL(text);
|
|
607
|
+
const params = url.searchParams;
|
|
608
|
+
if (params.get("state") !== state) {
|
|
547
609
|
throw new CliError(
|
|
548
|
-
'
|
|
610
|
+
'That URL belongs to a different login. Run "plot login" again.'
|
|
549
611
|
);
|
|
550
612
|
}
|
|
551
|
-
|
|
613
|
+
const code = params.get("code");
|
|
614
|
+
if (!code) throw new CliError("That URL carries no code.");
|
|
615
|
+
return { code, callback: `${url.origin}${url.pathname}` };
|
|
616
|
+
}
|
|
617
|
+
return { code: text };
|
|
618
|
+
}
|
|
619
|
+
async function waitForCode(opts) {
|
|
620
|
+
const abort = new AbortController();
|
|
621
|
+
let timer;
|
|
622
|
+
const sources = [];
|
|
623
|
+
const loopback = opts.loopback;
|
|
624
|
+
if (loopback) {
|
|
625
|
+
sources.push(
|
|
626
|
+
loopback.code.then((code) => ({
|
|
627
|
+
code,
|
|
628
|
+
redirectUri: loopback.redirectUri
|
|
629
|
+
}))
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
if (opts.prompt) {
|
|
633
|
+
const prompt = opts.prompt;
|
|
634
|
+
sources.push(
|
|
635
|
+
(async () => {
|
|
636
|
+
for (; ; ) {
|
|
637
|
+
const line = await prompt(abort.signal);
|
|
638
|
+
if (abort.signal.aborted) return new Promise(() => {
|
|
639
|
+
});
|
|
640
|
+
if (line === void 0) {
|
|
641
|
+
if (loopback) return new Promise(() => {
|
|
642
|
+
});
|
|
643
|
+
throw new CliError(
|
|
644
|
+
'No code was pasted. Run "plot login" again, and paste the code the browser shows.'
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (line.trim()) {
|
|
648
|
+
const pasted = codeFromPaste(line, opts.state);
|
|
649
|
+
return {
|
|
650
|
+
code: pasted.code,
|
|
651
|
+
redirectUri: pasted.callback ? loopback?.redirectUri ?? pasted.callback : OOB_REDIRECT_URI
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
})()
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
sources.push(
|
|
659
|
+
new Promise((_, reject) => {
|
|
660
|
+
timer = setTimeout(
|
|
661
|
+
() => reject(
|
|
662
|
+
new CliError(
|
|
663
|
+
'The login was not completed in time. Run "plot login" again.'
|
|
664
|
+
)
|
|
665
|
+
),
|
|
666
|
+
opts.timeoutMs ?? LOGIN_TIMEOUT_MS
|
|
667
|
+
);
|
|
668
|
+
})
|
|
669
|
+
);
|
|
670
|
+
try {
|
|
671
|
+
return await Promise.race(sources);
|
|
672
|
+
} finally {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
abort.abort();
|
|
675
|
+
opts.loopback?.close();
|
|
552
676
|
}
|
|
553
677
|
}
|
|
554
678
|
function defaultAgentName(env) {
|
|
@@ -587,15 +711,15 @@ function formatTime(value) {
|
|
|
587
711
|
return typeof value === "string" ? value : "";
|
|
588
712
|
}
|
|
589
713
|
function renderPages(pages2, next) {
|
|
590
|
-
const rows = pages2.map((
|
|
714
|
+
const rows = pages2.map((page2) => {
|
|
591
715
|
const extras = [
|
|
592
|
-
|
|
593
|
-
|
|
716
|
+
page2.unread ? `${page2.unread} unread` : "",
|
|
717
|
+
page2.canPost === false ? "read-only" : ""
|
|
594
718
|
].filter(Boolean);
|
|
595
|
-
const when = formatTime(
|
|
719
|
+
const when = formatTime(page2.updatedAt);
|
|
596
720
|
return [
|
|
597
|
-
|
|
598
|
-
|
|
721
|
+
page2.id,
|
|
722
|
+
page2.title || "Untitled",
|
|
599
723
|
extras.length ? `(${extras.join(", ")})` : "",
|
|
600
724
|
when
|
|
601
725
|
].filter(Boolean).join(" ");
|
|
@@ -669,8 +793,8 @@ function renderUpdate(update) {
|
|
|
669
793
|
}
|
|
670
794
|
|
|
671
795
|
// src/store.ts
|
|
672
|
-
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
673
|
-
import { dirname, join } from "node:path";
|
|
796
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
797
|
+
import { basename, dirname, join } from "node:path";
|
|
674
798
|
function credentialsPath(env, homeDir) {
|
|
675
799
|
const configHome = env.XDG_CONFIG_HOME?.trim() || join(homeDir, ".config");
|
|
676
800
|
return join(configHome, "plot", "credentials.json");
|
|
@@ -695,10 +819,14 @@ async function loadStore(path) {
|
|
|
695
819
|
);
|
|
696
820
|
}
|
|
697
821
|
async function saveStore(path, creds, fs = nodeFs) {
|
|
822
|
+
await writePrivateJson(path, creds, fs);
|
|
823
|
+
}
|
|
824
|
+
async function writePrivateJson(path, value, fs = nodeFs) {
|
|
698
825
|
const dir = dirname(path);
|
|
699
826
|
await fs.mkdir(dir, { recursive: true, mode: 448 });
|
|
700
|
-
const
|
|
701
|
-
|
|
827
|
+
const stem = basename(path).replace(/\.json$/, "");
|
|
828
|
+
const tmp = join(dir, `.${stem}.${process.pid}.${Date.now()}.tmp`);
|
|
829
|
+
await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}
|
|
702
830
|
`, {
|
|
703
831
|
mode: 384
|
|
704
832
|
});
|
|
@@ -716,13 +844,39 @@ async function updateStore(path, change) {
|
|
|
716
844
|
await saveStore(path, creds);
|
|
717
845
|
return creds;
|
|
718
846
|
}
|
|
847
|
+
function pendingLoginPath(env, homeDir) {
|
|
848
|
+
return join(dirname(credentialsPath(env, homeDir)), "pending-login.json");
|
|
849
|
+
}
|
|
850
|
+
async function savePendingLogin(path, pending) {
|
|
851
|
+
await writePrivateJson(path, pending);
|
|
852
|
+
}
|
|
853
|
+
async function loadPendingLogin(path) {
|
|
854
|
+
let text;
|
|
855
|
+
try {
|
|
856
|
+
text = await readFile(path, "utf8");
|
|
857
|
+
} catch (err) {
|
|
858
|
+
if (err.code === "ENOENT") return void 0;
|
|
859
|
+
throw err;
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
const parsed = JSON.parse(text);
|
|
863
|
+
if (parsed && typeof parsed.api === "string" && typeof parsed.verifier === "string" && typeof parsed.state === "string" && typeof parsed.agent === "string" && typeof parsed.createdAt === "number") {
|
|
864
|
+
return parsed;
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
}
|
|
868
|
+
return void 0;
|
|
869
|
+
}
|
|
870
|
+
async function deletePendingLogin(path) {
|
|
871
|
+
await rm(path, { force: true });
|
|
872
|
+
}
|
|
719
873
|
function tokenCandidates(opts) {
|
|
720
874
|
if (opts.flag) return [{ token: opts.flag, source: "flag" }];
|
|
721
875
|
const fromEnv = opts.env.PLOT_TOKEN?.trim();
|
|
722
876
|
if (fromEnv) return [{ token: fromEnv, source: "env" }];
|
|
723
877
|
const out = [];
|
|
724
|
-
const
|
|
725
|
-
if (
|
|
878
|
+
const page2 = opts.pageId ? opts.entry?.pages?.[opts.pageId] : void 0;
|
|
879
|
+
if (page2?.token) out.push({ token: page2.token, source: "page" });
|
|
726
880
|
if (opts.entry?.workspace?.token) {
|
|
727
881
|
out.push({ token: opts.entry.workspace.token, source: "workspace" });
|
|
728
882
|
}
|
|
@@ -803,11 +957,11 @@ async function forget(rt, candidate, pageId) {
|
|
|
803
957
|
}
|
|
804
958
|
async function saveCursor(rt, pageId, cursor) {
|
|
805
959
|
await updateOrigin(rt, (entry) => {
|
|
806
|
-
const
|
|
807
|
-
if (cursor === void 0) delete
|
|
960
|
+
const page2 = { ...entry.pages?.[pageId], cursor };
|
|
961
|
+
if (cursor === void 0) delete page2.cursor;
|
|
808
962
|
entry.pages ??= {};
|
|
809
|
-
if (Object.keys(
|
|
810
|
-
else entry.pages[pageId] =
|
|
963
|
+
if (Object.keys(page2).length === 0) delete entry.pages[pageId];
|
|
964
|
+
else entry.pages[pageId] = page2;
|
|
811
965
|
});
|
|
812
966
|
}
|
|
813
967
|
function needsWorkspace(err) {
|
|
@@ -867,40 +1021,114 @@ async function open(rt) {
|
|
|
867
1021
|
);
|
|
868
1022
|
}
|
|
869
1023
|
async function login(rt) {
|
|
1024
|
+
if (rt.values.code !== void 0) return loginWithCode(rt);
|
|
870
1025
|
const agent = agentName(rt);
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1026
|
+
const endpoints = await discoverEndpoints(rt.api, rt.origin);
|
|
1027
|
+
const verifier = newVerifier();
|
|
1028
|
+
const state = newState();
|
|
1029
|
+
const authorizeUrl = (redirectUri) => buildAuthorizeUrl({
|
|
1030
|
+
authorizationEndpoint: endpoints.authorizationEndpoint,
|
|
1031
|
+
redirectUri,
|
|
1032
|
+
challenge: challengeFor(verifier),
|
|
1033
|
+
state,
|
|
1034
|
+
agent
|
|
875
1035
|
});
|
|
876
|
-
const
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1036
|
+
const oobUrl = authorizeUrl(OOB_REDIRECT_URI);
|
|
1037
|
+
const noBrowser = Boolean(rt.values["no-browser"]);
|
|
1038
|
+
const open2 = (url) => noBrowser ? false : rt.ctx.openUrl(url).catch(() => false);
|
|
1039
|
+
const intro = `To connect ${agent} to a Plot workspace, sign in and approve it in the browser.`;
|
|
1040
|
+
if (!rt.ctx.stdinIsTTY) {
|
|
1041
|
+
await savePendingLogin(pendingPath(rt), {
|
|
1042
|
+
api: rt.origin,
|
|
1043
|
+
verifier,
|
|
1044
|
+
state,
|
|
1045
|
+
agent,
|
|
1046
|
+
createdAt: rt.ctx.now()
|
|
1047
|
+
});
|
|
1048
|
+
const opened = await open2(oobUrl);
|
|
1049
|
+
rt.ctx.err(
|
|
1050
|
+
[
|
|
1051
|
+
intro,
|
|
1052
|
+
urlLead(opened),
|
|
1053
|
+
"",
|
|
1054
|
+
` ${oobUrl}`,
|
|
1055
|
+
"",
|
|
1056
|
+
`After approving, run: plot login${apiFlag(rt)} --code <code>`,
|
|
1057
|
+
"The browser shows the code once you approve. It works for ten minutes."
|
|
1058
|
+
].join("\n")
|
|
1059
|
+
);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const loopback = noBrowser ? void 0 : await rt.ctx.listenLoopback(state);
|
|
1063
|
+
let code;
|
|
1064
|
+
try {
|
|
1065
|
+
const opened = await open2(
|
|
1066
|
+
loopback ? authorizeUrl(loopback.redirectUri) : oobUrl
|
|
1067
|
+
);
|
|
1068
|
+
rt.ctx.err([intro, urlLead(opened), "", ` ${oobUrl}`, ""].join("\n"));
|
|
1069
|
+
code = await waitForCode({
|
|
1070
|
+
loopback,
|
|
1071
|
+
state,
|
|
1072
|
+
timeoutMs: rt.ctx.loginTimeoutMs,
|
|
1073
|
+
prompt: (signal) => rt.ctx.promptLine(PASTE_PROMPT, signal)
|
|
1074
|
+
});
|
|
1075
|
+
} finally {
|
|
1076
|
+
loopback?.close();
|
|
1077
|
+
}
|
|
1078
|
+
await finishLogin(rt, endpoints.tokenEndpoint, verifier, code);
|
|
1079
|
+
}
|
|
1080
|
+
function urlLead(opened) {
|
|
1081
|
+
return opened ? "A browser tab should have opened. If it did not, or it is on another machine, open this URL:" : "Open this URL to sign in:";
|
|
1082
|
+
}
|
|
1083
|
+
function apiFlag(rt) {
|
|
1084
|
+
return rt.origin === DEFAULT_API_URL ? "" : ` --api ${rt.origin}`;
|
|
1085
|
+
}
|
|
1086
|
+
function pendingPath(rt) {
|
|
1087
|
+
return pendingLoginPath(rt.ctx.env, rt.ctx.homeDir);
|
|
1088
|
+
}
|
|
1089
|
+
async function loginWithCode(rt) {
|
|
1090
|
+
const text = typeof rt.values.code === "string" ? rt.values.code.trim() : "";
|
|
1091
|
+
if (!text) {
|
|
1092
|
+
throw new UsageError("--code needs the code the browser showed.");
|
|
1093
|
+
}
|
|
1094
|
+
const path = pendingPath(rt);
|
|
1095
|
+
const pending = await loadPendingLogin(path);
|
|
1096
|
+
if (!pending) {
|
|
1097
|
+
throw new CliError(
|
|
1098
|
+
`No login is waiting for a code. Run "plot login${apiFlag(rt)}" first, approve in the browser, then run "plot login${apiFlag(rt)} --code <code>" with the code it shows.`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (pending.api !== rt.origin) {
|
|
1102
|
+
throw new CliError(
|
|
1103
|
+
`The login waiting for a code was started against ${pending.api}, not ${rt.origin}. Run "plot login --api ${pending.api} --code <code>", or start again with "plot login${apiFlag(rt)}".`
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (rt.ctx.now() - pending.createdAt > PENDING_LOGIN_MS) {
|
|
1107
|
+
await deletePendingLogin(path);
|
|
1108
|
+
throw new CliError(
|
|
1109
|
+
`The login waiting for a code is more than ten minutes old. Run "plot login${apiFlag(rt)}" again.`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
const pasted = codeFromPaste(text, pending.state);
|
|
1113
|
+
const endpoints = await discoverEndpoints(rt.api, rt.origin);
|
|
1114
|
+
await finishLogin(rt, endpoints.tokenEndpoint, pending.verifier, {
|
|
1115
|
+
code: pasted.code,
|
|
1116
|
+
redirectUri: OOB_REDIRECT_URI
|
|
1117
|
+
});
|
|
1118
|
+
await deletePendingLogin(path);
|
|
1119
|
+
}
|
|
1120
|
+
async function finishLogin(rt, tokenEndpoint, verifier, received) {
|
|
1121
|
+
const token = await rt.api.exchangeCode(tokenEndpoint, {
|
|
1122
|
+
code: received.code,
|
|
1123
|
+
code_verifier: verifier,
|
|
1124
|
+
client_id: CLIENT_ID,
|
|
1125
|
+
redirect_uri: received.redirectUri
|
|
896
1126
|
});
|
|
1127
|
+
const me = await rt.api.me(token.access_token);
|
|
897
1128
|
const workspace = me.workspace?.name;
|
|
1129
|
+
const expiresAt = me.expiresAt !== void 0 ? formatTime(me.expiresAt) : typeof token.expires_in === "number" ? formatTime(rt.ctx.now() + token.expires_in * 1e3) : void 0;
|
|
898
1130
|
await updateOrigin(rt, (entry) => {
|
|
899
|
-
entry.workspace = {
|
|
900
|
-
token,
|
|
901
|
-
workspace,
|
|
902
|
-
expiresAt: me.expiresAt === void 0 ? void 0 : formatTime(me.expiresAt)
|
|
903
|
-
};
|
|
1131
|
+
entry.workspace = { token: token.access_token, workspace, expiresAt };
|
|
904
1132
|
});
|
|
905
1133
|
rt.ctx.out(`Connected${workspace ? ` to ${workspace}` : ""}. Try: plot pages`);
|
|
906
1134
|
}
|
|
@@ -917,23 +1145,23 @@ async function logout(rt) {
|
|
|
917
1145
|
token: entry.workspace.token
|
|
918
1146
|
});
|
|
919
1147
|
}
|
|
920
|
-
for (const [id,
|
|
921
|
-
if (
|
|
1148
|
+
for (const [id, page2] of Object.entries(entry.pages ?? {})) {
|
|
1149
|
+
if (page2.token) {
|
|
922
1150
|
targets.push({
|
|
923
|
-
label: `page "${
|
|
924
|
-
token:
|
|
1151
|
+
label: `page "${page2.title ?? id}"`,
|
|
1152
|
+
token: page2.token
|
|
925
1153
|
});
|
|
926
1154
|
}
|
|
927
1155
|
}
|
|
928
1156
|
} else if (rt.positionals[0]) {
|
|
929
1157
|
const pageId = parsePageRef(rt.positionals[0]);
|
|
930
|
-
const
|
|
931
|
-
if (!
|
|
1158
|
+
const page2 = entry.pages?.[pageId];
|
|
1159
|
+
if (!page2?.token) {
|
|
932
1160
|
throw new CliError(`No stored credential for page ${pageId}.`);
|
|
933
1161
|
}
|
|
934
1162
|
targets.push({
|
|
935
|
-
label: `page "${
|
|
936
|
-
token:
|
|
1163
|
+
label: `page "${page2.title ?? pageId}"`,
|
|
1164
|
+
token: page2.token
|
|
937
1165
|
});
|
|
938
1166
|
} else if (entry.workspace) {
|
|
939
1167
|
targets.push({
|
|
@@ -967,13 +1195,13 @@ async function logout(rt) {
|
|
|
967
1195
|
}
|
|
968
1196
|
await updateOrigin(rt, (e) => {
|
|
969
1197
|
if (e.workspace && revoked.has(e.workspace.token)) delete e.workspace;
|
|
970
|
-
for (const [id,
|
|
971
|
-
if (
|
|
1198
|
+
for (const [id, page2] of Object.entries(e.pages ?? {})) {
|
|
1199
|
+
if (page2.token && revoked.has(page2.token)) {
|
|
972
1200
|
if (rt.values.all) delete e.pages[id];
|
|
973
1201
|
else {
|
|
974
|
-
delete
|
|
975
|
-
delete
|
|
976
|
-
if (!
|
|
1202
|
+
delete page2.token;
|
|
1203
|
+
delete page2.expiresAt;
|
|
1204
|
+
if (!page2.cursor) delete e.pages[id];
|
|
977
1205
|
}
|
|
978
1206
|
}
|
|
979
1207
|
}
|
|
@@ -993,20 +1221,20 @@ async function whoami(rt) {
|
|
|
993
1221
|
}
|
|
994
1222
|
rt.ctx.out(describeMe(me.me, me.source, rt.origin));
|
|
995
1223
|
const others = Object.entries(entry.pages ?? {}).filter(
|
|
996
|
-
([id,
|
|
1224
|
+
([id, page2]) => page2.token && id !== pageId
|
|
997
1225
|
);
|
|
998
1226
|
if (others.length > 0 && !pageId) {
|
|
999
1227
|
rt.ctx.out(
|
|
1000
1228
|
["", "Stored page credentials:"].concat(
|
|
1001
1229
|
others.map(
|
|
1002
|
-
([id,
|
|
1230
|
+
([id, page2]) => ` ${id} ${page2.title ?? ""}${page2.expiresAt ? ` (expires ${page2.expiresAt})` : ""}`
|
|
1003
1231
|
)
|
|
1004
1232
|
).join("\n")
|
|
1005
1233
|
);
|
|
1006
1234
|
}
|
|
1007
1235
|
}
|
|
1008
1236
|
function describeMe(me, source, origin) {
|
|
1009
|
-
const
|
|
1237
|
+
const page2 = me.page ? `${me.page.title || "Untitled"} (${me.page.id})${me.page.canPost === false ? ", read-only" : me.page.canPost ? ", can post" : ""}` : void 0;
|
|
1010
1238
|
const time = (value) => value === void 0 || value === null ? void 0 : formatTime(value);
|
|
1011
1239
|
const rows = [
|
|
1012
1240
|
["API", origin],
|
|
@@ -1014,7 +1242,7 @@ function describeMe(me, source, origin) {
|
|
|
1014
1242
|
["Agent", me.agent],
|
|
1015
1243
|
["Granted by", me.grantedBy],
|
|
1016
1244
|
["Workspace", me.workspace?.name],
|
|
1017
|
-
["Page",
|
|
1245
|
+
["Page", page2],
|
|
1018
1246
|
["Connected", time(me.connectedAt)],
|
|
1019
1247
|
["Last used", time(me.lastUsedAt)],
|
|
1020
1248
|
["Expires", time(me.expiresAt)],
|
|
@@ -1356,8 +1584,8 @@ var COMMANDS = {
|
|
|
1356
1584
|
run: login,
|
|
1357
1585
|
options: {
|
|
1358
1586
|
agent: { type: "string" },
|
|
1359
|
-
|
|
1360
|
-
|
|
1587
|
+
"no-browser": { type: "boolean" },
|
|
1588
|
+
code: { type: "string" }
|
|
1361
1589
|
}
|
|
1362
1590
|
},
|
|
1363
1591
|
logout: { run: logout, options: { all: { type: "boolean" } } },
|
|
@@ -1452,6 +1680,107 @@ async function run(argv, ctx) {
|
|
|
1452
1680
|
// src/context.ts
|
|
1453
1681
|
import { spawn } from "node:child_process";
|
|
1454
1682
|
import { homedir } from "node:os";
|
|
1683
|
+
import { createInterface } from "node:readline";
|
|
1684
|
+
|
|
1685
|
+
// src/loopback.ts
|
|
1686
|
+
import { createServer } from "node:http";
|
|
1687
|
+
function startLoopback(state) {
|
|
1688
|
+
let resolveCode;
|
|
1689
|
+
let rejectCode;
|
|
1690
|
+
const code = new Promise((resolve, reject) => {
|
|
1691
|
+
resolveCode = resolve;
|
|
1692
|
+
rejectCode = reject;
|
|
1693
|
+
});
|
|
1694
|
+
code.catch(() => {
|
|
1695
|
+
});
|
|
1696
|
+
let done = false;
|
|
1697
|
+
const server = createServer((req, res) => {
|
|
1698
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1699
|
+
if (url.pathname !== "/callback") {
|
|
1700
|
+
return page(res, 404, "Not found", "Nothing here.");
|
|
1701
|
+
}
|
|
1702
|
+
const params = url.searchParams;
|
|
1703
|
+
if (done) {
|
|
1704
|
+
return page(
|
|
1705
|
+
res,
|
|
1706
|
+
409,
|
|
1707
|
+
"Already finished",
|
|
1708
|
+
"This login has already finished. Return to the terminal."
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
if (params.get("state") !== state) {
|
|
1712
|
+
return page(
|
|
1713
|
+
res,
|
|
1714
|
+
400,
|
|
1715
|
+
"Not this login",
|
|
1716
|
+
'This sign-in does not belong to the login waiting in the terminal, so it was ignored. Run "plot login" again if you meant to connect.'
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
const error = params.get("error");
|
|
1720
|
+
if (error) {
|
|
1721
|
+
done = true;
|
|
1722
|
+
const failure = oauthFailure(error, params.get("error_description"));
|
|
1723
|
+
page(res, 403, "Plot was not connected", failure.message);
|
|
1724
|
+
rejectCode(failure);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const value = params.get("code");
|
|
1728
|
+
if (!value) {
|
|
1729
|
+
return page(res, 400, "No code", "The callback carried no code.");
|
|
1730
|
+
}
|
|
1731
|
+
done = true;
|
|
1732
|
+
page(
|
|
1733
|
+
res,
|
|
1734
|
+
200,
|
|
1735
|
+
"Plot is connected",
|
|
1736
|
+
"You can close this tab and return to the terminal."
|
|
1737
|
+
);
|
|
1738
|
+
resolveCode(value);
|
|
1739
|
+
});
|
|
1740
|
+
return new Promise((resolve, reject) => {
|
|
1741
|
+
server.once("error", reject);
|
|
1742
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1743
|
+
server.off("error", reject);
|
|
1744
|
+
const { port } = server.address();
|
|
1745
|
+
let closed = false;
|
|
1746
|
+
resolve({
|
|
1747
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
1748
|
+
code,
|
|
1749
|
+
close: () => {
|
|
1750
|
+
if (closed) return;
|
|
1751
|
+
closed = true;
|
|
1752
|
+
server.close();
|
|
1753
|
+
server.closeIdleConnections();
|
|
1754
|
+
setTimeout(() => server.closeAllConnections(), 500).unref();
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
function oauthFailure(error, description) {
|
|
1761
|
+
if (description) return new CliError(`Login failed: ${description}`);
|
|
1762
|
+
if (error === "access_denied") {
|
|
1763
|
+
return new CliError("Login failed: access was denied in the browser.");
|
|
1764
|
+
}
|
|
1765
|
+
return new CliError(`Login failed: the browser returned "${error}".`);
|
|
1766
|
+
}
|
|
1767
|
+
function page(res, status, title, message) {
|
|
1768
|
+
const body = `<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem"><h1 style="font-size:1.25rem">${escape(title)}</h1><p>${escape(message)}</p></body>`;
|
|
1769
|
+
res.writeHead(status, {
|
|
1770
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1771
|
+
"Cache-Control": "no-store",
|
|
1772
|
+
Connection: "close"
|
|
1773
|
+
});
|
|
1774
|
+
res.end(body);
|
|
1775
|
+
}
|
|
1776
|
+
function escape(text) {
|
|
1777
|
+
return text.replace(
|
|
1778
|
+
/[&<>"']/g,
|
|
1779
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// src/context.ts
|
|
1455
1784
|
function nodeContext() {
|
|
1456
1785
|
return {
|
|
1457
1786
|
env: process.env,
|
|
@@ -1469,10 +1798,41 @@ function nodeContext() {
|
|
|
1469
1798
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
1470
1799
|
now: () => Date.now(),
|
|
1471
1800
|
openUrl: (url) => openInBrowser(url, process.platform),
|
|
1801
|
+
listenLoopback: (state) => startLoopback(state).catch(() => void 0),
|
|
1802
|
+
promptLine: (question, signal) => promptLine(question, signal),
|
|
1803
|
+
loginTimeoutMs: LOGIN_TIMEOUT_MS,
|
|
1472
1804
|
cwd: process.cwd(),
|
|
1473
1805
|
homeDir: homedir()
|
|
1474
1806
|
};
|
|
1475
1807
|
}
|
|
1808
|
+
function promptLine(question, signal) {
|
|
1809
|
+
return new Promise((resolve) => {
|
|
1810
|
+
if (signal.aborted) return resolve(void 0);
|
|
1811
|
+
const rl = createInterface({
|
|
1812
|
+
input: process.stdin,
|
|
1813
|
+
output: process.stderr,
|
|
1814
|
+
terminal: Boolean(process.stdin.isTTY)
|
|
1815
|
+
});
|
|
1816
|
+
let answered = false;
|
|
1817
|
+
rl.once("close", () => {
|
|
1818
|
+
if (!answered) resolve(void 0);
|
|
1819
|
+
});
|
|
1820
|
+
signal.addEventListener(
|
|
1821
|
+
"abort",
|
|
1822
|
+
() => {
|
|
1823
|
+
if (answered) return;
|
|
1824
|
+
rl.close();
|
|
1825
|
+
process.stderr.write("\n");
|
|
1826
|
+
},
|
|
1827
|
+
{ once: true }
|
|
1828
|
+
);
|
|
1829
|
+
rl.question(question, (answer) => {
|
|
1830
|
+
answered = true;
|
|
1831
|
+
rl.close();
|
|
1832
|
+
resolve(answer);
|
|
1833
|
+
});
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1476
1836
|
function openInBrowser(url, platform) {
|
|
1477
1837
|
const command = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : null;
|
|
1478
1838
|
if (!command) return Promise.resolve(false);
|