@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.
Files changed (3) hide show
  1. package/README.md +36 -13
  2. package/dist/plot.js +499 -139
  3. 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"` mints a token on this
30
- machine, opens `<site>/connect?hash=…&agent=…&fp=…` for a person to approve,
31
- and waits up to ten minutes. Only the hash goes to the browser. The terminal
32
- prints a six-character code (the hash's first six characters) and the page
33
- asks the person to type it, so a link sent on by someone else cannot be
34
- approved by mistake. The URL is printed too, for SSH and sandboxes. The site
35
- is `--site`, else `PLOT_SITE_URL`, else the web app the API names in its
36
- OAuth metadata, so `--api` at a local or preview deployment approves there
37
- rather than on production.
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] [--site] [--no-browser]` | Connect a workspace, approved in the browser |
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, minting and
170
- hashing) is a private workspace package: the bundle inlines it, so the
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.0";
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 an `--api` it was not told the
130
- * site of.
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, page = {}) {
193
+ async pages(token, page2 = {}) {
143
194
  return json(
144
195
  await request("GET", "/v1/pages", {
145
196
  token,
146
- query: { limit: page.limit, cursor: page.next }
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, page = {}) {
246
+ async search(token, q, page2 = {}) {
196
247
  return json(
197
248
  await request("GET", "/v1/search", {
198
249
  token,
199
- query: { q, limit: page.limit, cursor: page.next }
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>] [--site <url>] Connect a workspace (approve in the browser)
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; a person approves
394
- it in the browser, so show them the URL
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>] [--site <url>] [--no-browser]
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
- Connects a whole workspace. Mints a token here, opens the browser at
431
- <site>/connect to approve it (and prints the URL), then waits up to ten
432
- minutes for the approval. Type the code it prints on that page. --site
433
- defaults to PLOT_SITE_URL, else the site the API names (its OAuth metadata),
434
- so --api at a local or preview deployment approves on that deployment's own
435
- web app.`,
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
- var LOGIN_POLL_INTERVAL_MS = 2e3;
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
- function fingerprint(hash) {
506
- return hash.slice(0, 6);
507
- }
508
- function buildLoginUrl(site, hash, agent) {
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
- async function resolveLoginSite(opts) {
516
- if (opts.explicit) return opts.explicit;
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 endpoint = (await opts.api.authorizationServer()).authorization_endpoint;
520
- if (typeof endpoint === "string") {
521
- const url = new URL(endpoint);
522
- if (url.protocol === "https:" || url.protocol === "http:") {
523
- return url.origin;
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 = "its OAuth metadata names no authorization endpoint";
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 tell which Plot site approves logins for ${opts.apiBase} (${reason}). Pass --site <url> (or set PLOT_SITE_URL) to the web app that deployment belongs to.`
580
+ `Could not start a login with ${apiBase}: ${reason}. Check --api (or PLOT_API_URL).`
533
581
  );
534
582
  }
535
- var FATAL = /* @__PURE__ */ new Set(["revoked", "disabled", "denied"]);
536
- async function waitForApproval(opts) {
537
- const interval = opts.intervalMs ?? LOGIN_POLL_INTERVAL_MS;
538
- const deadline = opts.now() + (opts.timeoutMs ?? LOGIN_TIMEOUT_MS);
539
- for (; ; ) {
540
- try {
541
- return await opts.api.me(opts.token);
542
- } catch (err) {
543
- if (!(err instanceof ApiError)) throw err;
544
- if (FATAL.has(err.code)) throw new CliError(err.message);
545
- }
546
- if (opts.now() + interval > deadline) {
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
- 'The login was not approved in time. Run "plot login" again.'
610
+ 'That URL belongs to a different login. Run "plot login" again.'
549
611
  );
550
612
  }
551
- await opts.sleep(interval);
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((page) => {
714
+ const rows = pages2.map((page2) => {
591
715
  const extras = [
592
- page.unread ? `${page.unread} unread` : "",
593
- page.canPost === false ? "read-only" : ""
716
+ page2.unread ? `${page2.unread} unread` : "",
717
+ page2.canPost === false ? "read-only" : ""
594
718
  ].filter(Boolean);
595
- const when = formatTime(page.updatedAt);
719
+ const when = formatTime(page2.updatedAt);
596
720
  return [
597
- page.id,
598
- page.title || "Untitled",
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 tmp = join(dir, `.credentials.${process.pid}.${Date.now()}.tmp`);
701
- await fs.writeFile(tmp, `${JSON.stringify(creds, null, 2)}
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 page = opts.pageId ? opts.entry?.pages?.[opts.pageId] : void 0;
725
- if (page?.token) out.push({ token: page.token, source: "page" });
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 page = { ...entry.pages?.[pageId], cursor };
807
- if (cursor === void 0) delete page.cursor;
960
+ const page2 = { ...entry.pages?.[pageId], cursor };
961
+ if (cursor === void 0) delete page2.cursor;
808
962
  entry.pages ??= {};
809
- if (Object.keys(page).length === 0) delete entry.pages[pageId];
810
- else entry.pages[pageId] = page;
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 site = await resolveLoginSite({
872
- explicit: explicitSiteBase(stringFlag(rt, "site"), rt.ctx.env),
873
- apiBase: rt.origin,
874
- api: rt.api
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 token = mintAgentToken(AGENT_SESSION_PREFIX);
877
- const hash = await hashAgentToken(token);
878
- const url = buildLoginUrl(site, hash, agent);
879
- rt.ctx.err(
880
- [
881
- `To connect ${agent} to a Plot workspace, open this URL and approve it:`,
882
- "",
883
- ` ${url}`,
884
- "",
885
- `Type this code on the page: ${fingerprint(hash)}`,
886
- "",
887
- "Waiting for approval (up to 10 minutes)\u2026"
888
- ].join("\n")
889
- );
890
- if (!rt.values["no-browser"]) await rt.ctx.openUrl(url);
891
- const me = await waitForApproval({
892
- api: rt.api,
893
- token,
894
- sleep: rt.ctx.sleep,
895
- now: rt.ctx.now
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, page] of Object.entries(entry.pages ?? {})) {
921
- if (page.token) {
1148
+ for (const [id, page2] of Object.entries(entry.pages ?? {})) {
1149
+ if (page2.token) {
922
1150
  targets.push({
923
- label: `page "${page.title ?? id}"`,
924
- token: page.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 page = entry.pages?.[pageId];
931
- if (!page?.token) {
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 "${page.title ?? pageId}"`,
936
- token: page.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, page] of Object.entries(e.pages ?? {})) {
971
- if (page.token && revoked.has(page.token)) {
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 page.token;
975
- delete page.expiresAt;
976
- if (!page.cursor) delete e.pages[id];
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, page]) => page.token && id !== pageId
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, page]) => ` ${id} ${page.title ?? ""}${page.expiresAt ? ` (expires ${page.expiresAt})` : ""}`
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 page = me.page ? `${me.page.title || "Untitled"} (${me.page.id})${me.page.canPost === false ? ", read-only" : me.page.canPost ? ", can post" : ""}` : void 0;
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", 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
- site: { type: "string" },
1360
- "no-browser": { type: "boolean" }
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plotio/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Read Plot pages, follow a meeting as it happens, and post back, from any agent with a shell.",
5
5
  "type": "module",
6
6
  "license": "MIT",