@coldtea/pr-lens-cli 0.7.0 → 0.8.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.
Files changed (90) hide show
  1. package/README.md +31 -3
  2. package/dist/account.d.ts +76 -0
  3. package/dist/account.d.ts.map +1 -0
  4. package/dist/account.js +211 -0
  5. package/dist/account.js.map +1 -0
  6. package/dist/auth.d.ts +40 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +121 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/canvas/api.d.ts +35 -1
  11. package/dist/canvas/api.d.ts.map +1 -1
  12. package/dist/canvas/api.js +57 -1
  13. package/dist/canvas/api.js.map +1 -1
  14. package/dist/canvas/claim.d.ts +3 -0
  15. package/dist/canvas/claim.d.ts.map +1 -0
  16. package/dist/canvas/claim.js +84 -0
  17. package/dist/canvas/claim.js.map +1 -0
  18. package/dist/canvas/delete.js +3 -3
  19. package/dist/canvas/delete.js.map +1 -1
  20. package/dist/canvas/registry.d.ts +8 -2
  21. package/dist/canvas/registry.d.ts.map +1 -1
  22. package/dist/canvas/registry.js +15 -2
  23. package/dist/canvas/registry.js.map +1 -1
  24. package/dist/canvas/write.d.ts +11 -2
  25. package/dist/canvas/write.d.ts.map +1 -1
  26. package/dist/canvas/write.js +27 -8
  27. package/dist/canvas/write.js.map +1 -1
  28. package/dist/cli.d.ts.map +1 -1
  29. package/dist/cli.js +8 -2
  30. package/dist/cli.js.map +1 -1
  31. package/dist/commands/auth.d.ts +9 -0
  32. package/dist/commands/auth.d.ts.map +1 -0
  33. package/dist/commands/auth.js +369 -0
  34. package/dist/commands/auth.js.map +1 -0
  35. package/dist/commands/canvas.d.ts.map +1 -1
  36. package/dist/commands/canvas.js +162 -21
  37. package/dist/commands/canvas.js.map +1 -1
  38. package/dist/commands/render.d.ts +1 -1
  39. package/dist/commands/render.d.ts.map +1 -1
  40. package/dist/commands/render.js +6 -3
  41. package/dist/commands/render.js.map +1 -1
  42. package/dist/config-home.d.ts +4 -0
  43. package/dist/config-home.d.ts.map +1 -0
  44. package/dist/config-home.js +38 -0
  45. package/dist/config-home.js.map +1 -0
  46. package/dist/errors.d.ts +1 -1
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js.map +1 -1
  49. package/dist/install.d.ts +5 -0
  50. package/dist/install.d.ts.map +1 -0
  51. package/dist/install.js +83 -0
  52. package/dist/install.js.map +1 -0
  53. package/dist/skill-content.generated.d.ts +1 -1
  54. package/dist/skill-content.generated.d.ts.map +1 -1
  55. package/dist/skill-content.generated.js +1 -1
  56. package/dist/skill-content.generated.js.map +1 -1
  57. package/dist/slug.d.ts +7 -0
  58. package/dist/slug.d.ts.map +1 -0
  59. package/dist/slug.js +23 -0
  60. package/dist/slug.js.map +1 -0
  61. package/dist/terminal.d.ts +5 -0
  62. package/dist/terminal.d.ts.map +1 -1
  63. package/dist/terminal.js +7 -0
  64. package/dist/terminal.js.map +1 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/dist/workspace.d.ts +4 -0
  68. package/dist/workspace.d.ts.map +1 -1
  69. package/dist/workspace.js +26 -4
  70. package/dist/workspace.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/account.ts +302 -0
  73. package/src/auth.ts +183 -0
  74. package/src/canvas/api.ts +114 -2
  75. package/src/canvas/claim.ts +127 -0
  76. package/src/canvas/delete.ts +3 -3
  77. package/src/canvas/registry.ts +24 -2
  78. package/src/canvas/write.ts +37 -6
  79. package/src/cli.ts +8 -2
  80. package/src/commands/auth.ts +560 -0
  81. package/src/commands/canvas.ts +238 -18
  82. package/src/commands/render.ts +7 -3
  83. package/src/config-home.ts +45 -0
  84. package/src/errors.ts +5 -1
  85. package/src/install.ts +107 -0
  86. package/src/skill-content.generated.ts +1 -1
  87. package/src/slug.ts +26 -0
  88. package/src/terminal.ts +14 -0
  89. package/src/version.ts +1 -1
  90. package/src/workspace.ts +31 -4
@@ -2,13 +2,17 @@ import { assertNever } from "@coldtea/pr-lens-schema";
2
2
 
3
3
  import {
4
4
  fetchCanvas,
5
+ listOwnedCanvases,
5
6
  mintCanvas,
6
7
  pushCanvas,
7
8
  verifyWriteToken,
9
+ type CanvasPreview,
10
+ type OwnedCanvas,
8
11
  } from "../canvas/api.js";
9
12
  import {
10
13
  ensureRegistryHome,
11
14
  findBySource,
15
+ findByTitle,
12
16
  findCanvas,
13
17
  isCanvasId,
14
18
  mintWriteToken,
@@ -25,31 +29,50 @@ import {
25
29
  type Registered,
26
30
  } from "../canvas/registry.js";
27
31
  import { writeJsonFile } from "../io.js";
32
+ import { readInstallId } from "../install.js";
28
33
  import { readGraphDoc } from "../document.js";
29
34
  import type { Terminal } from "../terminal.js";
30
- import { WORKSPACE_DIR } from "../workspace.js";
35
+ import { drawings, WORKSPACE_DIR } from "../workspace.js";
36
+ import { readToken, requireToken } from "../auth.js";
37
+ import { claimCommand } from "../canvas/claim.js";
31
38
  import { deleteCommand } from "../canvas/delete.js";
32
- import { parseOptions, readString } from "../args.js";
39
+ import { parseOptions, readBoolean, readString } from "../args.js";
33
40
  import { PrLensCliError, usageError } from "../errors.js";
34
- import { DEFAULT_API, API_ENV, readApi, requireSameApi, requireWriteToken, settleRotation, settlePendingRotation } from "../canvas/write.js";
41
+ import { DEFAULT_API, API_ENV, readApi, requireSameApi, requireWriteToken, settleRotation, settlePendingRotation, writeCredential } from "../canvas/write.js";
35
42
 
36
- const DEFAULT_SOURCE = `${WORKSPACE_DIR}/drawn.graph.json`;
43
+ const DRAWN = "drawn.graph.json";
44
+
45
+ /** Where 0.7.0 rendered; its registry entries still name this path. */
46
+ const LEGACY_SOURCE = `${WORKSPACE_DIR}/${DRAWN}`;
37
47
  const DEFAULT_OUT = `${WORKSPACE_DIR}/graph.json`;
38
48
 
39
- const SUBCOMMANDS = ["push", "pull", "rotate", "delete"] as const;
49
+ const SUBCOMMANDS = [
50
+ "list",
51
+ "push",
52
+ "pull",
53
+ "claim",
54
+ "rotate",
55
+ "delete",
56
+ ] as const;
40
57
  type Subcommand = (typeof SUBCOMMANDS)[number];
41
58
 
42
59
  const isSubcommand = (value: string): value is Subcommand =>
43
60
  SUBCOMMANDS.some((subcommand) => subcommand === value);
44
61
 
45
- export const USAGE = `pr-lens canvas <push | pull | rotate | delete> [options]
62
+ export const USAGE = `pr-lens canvas <list | push | pull | claim | rotate | delete> [options]
46
63
 
47
64
  Keeps a graph document on the PR Lens app as a canvas: a page anyone you share
48
65
  it with can read, and an SVG a README can embed. The write token lands in
49
66
  ${REGISTRY_PATH}, which git ignores; the edit link carries the same token
50
67
  in its fragment, so share the view link and keep the edit link to yourself.
51
68
 
52
- pr-lens canvas push [graph.json] send the document (default ${DEFAULT_SOURCE})
69
+ pr-lens canvas list every canvas this checkout knows
70
+ --remote add every canvas the signed-in account owns
71
+ --api <url> only the canvases at that app
72
+ --json the same listing, for scripts
73
+
74
+ pr-lens canvas push [graph.json] send the document (default the checkout's
75
+ only drawing, under ${WORKSPACE_DIR}/)
53
76
  --canvas <id|name> which canvas (default the one this document
54
77
  was pushed to before, else a new one)
55
78
  --name <name> what to call a new canvas (default the document's title)
@@ -58,6 +81,10 @@ in its fragment, so share the view link and keep the edit link to yourself.
58
81
  --canvas <id|name> which canvas, when no url or id is given
59
82
  -o, --out <file> where to write it (default ${DEFAULT_OUT})
60
83
 
84
+ pr-lens canvas claim <id|name> take a canvas onto the account this machine
85
+ is signed in to, proving it with the write
86
+ token; that token is retired in the same step
87
+
61
88
  pr-lens canvas rotate mint a new write token; the old edit link stops working
62
89
  --canvas <id|name> which canvas (default the checkout's only canvas)
63
90
 
@@ -140,7 +167,8 @@ const recordPull = (current: CanvasRegistry, pull: PullRecord): Recorded => {
140
167
  name: entry?.name ?? pull.fetched?.title ?? pull.id,
141
168
  source:
142
169
  entry?.source ??
143
- (pull.fetched === undefined ? DEFAULT_SOURCE : sourceKey(pull.out)),
170
+ // No document, so no source a bare push could resolve to.
171
+ (pull.fetched === undefined ? undefined : sourceKey(pull.out)),
144
172
  api: pull.api,
145
173
  ...(pending === undefined ? {} : { pending }),
146
174
  ...(kept === undefined ? {} : { writeToken: kept }),
@@ -187,6 +215,38 @@ const tellRecorded = (
187
215
  }
188
216
  };
189
217
 
218
+ /** Like `onlyCanvas`: none or several is reported, never guessed at. */
219
+ const onlyDrawing = async (): Promise<string> => {
220
+ const found = await drawings();
221
+ const [only, ...more] = found;
222
+
223
+ if (only === undefined)
224
+ throw usageError(
225
+ `no drawing in ${WORKSPACE_DIR}/`,
226
+ "pr-lens render <graph.json> draws one, or name the document to push",
227
+ );
228
+
229
+ if (more.length > 0)
230
+ throw usageError(
231
+ `${found.length} drawings in ${WORKSPACE_DIR}/`,
232
+ `name the one to push: ${found.join(", ")}`,
233
+ );
234
+
235
+ return only;
236
+ };
237
+
238
+ /**
239
+ * Without this, the first push after upgrading from 0.7.0 would orphan the
240
+ * old canvas. The push records the new path, so it is adopted once.
241
+ */
242
+ const adoptLegacy = (
243
+ registry: CanvasRegistry,
244
+ source: string,
245
+ ): Registered | undefined =>
246
+ sourceKey(source) === sourceKey(LEGACY_SOURCE)
247
+ ? undefined
248
+ : findBySource(registry, LEGACY_SOURCE);
249
+
190
250
  const push = async (
191
251
  args: readonly string[],
192
252
  terminal: Terminal,
@@ -202,16 +262,19 @@ const push = async (
202
262
  `push takes one graph document, got ${positionals.length}`,
203
263
  );
204
264
 
205
- const source = positionals[0] ?? DEFAULT_SOURCE;
265
+ const source = positionals[0] ?? (await onlyDrawing());
206
266
  const document = await readGraphDoc(source);
207
267
  const api = readApi(values.api, env);
208
268
  await ensureRegistryHome(terminal);
209
269
  const registry = await readRegistry();
210
270
 
211
271
  const ref = readString(values.canvas, "canvas");
272
+ // A push onto a known path updates, so a redraw moves the canvas on a revision.
212
273
  const known =
213
274
  ref === undefined
214
- ? findBySource(registry, source)
275
+ ? findBySource(registry, source) ??
276
+ adoptLegacy(registry, source) ??
277
+ findByTitle(registry, document.title)
215
278
  : findCanvas(registry, ref);
216
279
 
217
280
  const registered: Registered =
@@ -224,7 +287,12 @@ const push = async (
224
287
  ref === undefined ? findBySource(current, source) : undefined;
225
288
  if (meanwhile !== undefined) return meanwhile;
226
289
 
227
- const minted = await mintCanvas(api);
290
+ // Only a mint names the machine.
291
+ const minted = await mintCanvas(
292
+ api,
293
+ await readInstallId(env, api),
294
+ await readToken(env, api),
295
+ );
228
296
  const entry: CanvasEntry = {
229
297
  name: readString(values.name, "name") ?? document.title,
230
298
  source: sourceKey(source),
@@ -254,12 +322,12 @@ const push = async (
254
322
  return { id: minted.id, entry };
255
323
  }));
256
324
 
257
- const target = await settlePendingRotation(api, registered, terminal);
325
+ const target = await settlePendingRotation(api, registered, terminal, env);
258
326
 
259
327
  const pushed = await pushCanvas(
260
328
  api,
261
329
  target.id,
262
- requireWriteToken(target),
330
+ await writeCredential(target, env, api),
263
331
  target.entry.rev,
264
332
  document,
265
333
  );
@@ -380,7 +448,13 @@ const rotate = async (
380
448
  await ensureRegistryHome(terminal);
381
449
  const registry = await readRegistry();
382
450
  const ref = readString(values.canvas, "canvas");
383
- const { id } = selectCanvas(registry, ref);
451
+ const selected = selectCanvas(registry, ref);
452
+ const { id } = selected;
453
+
454
+ // Checked before the pending token is saved, or an unauthorised rotation
455
+ // would leave one behind for the next push to carry out.
456
+ requireSameApi(api, selected);
457
+ await writeCredential(selected, env, api);
384
458
 
385
459
  // Saved before the request, so a lost answer cannot lose it; chosen under
386
460
  // the lock, so two rotations at once finish the same one.
@@ -388,10 +462,7 @@ const rotate = async (
388
462
  await updateRegistry((current) => {
389
463
  const entry = current.canvases[id];
390
464
  if (entry === undefined) return;
391
- // Without a token, a pending rotation would be carried out by whatever
392
- // token arrives next, retiring the very link that brought it.
393
465
  requireSameApi(api, { id, entry });
394
- requireWriteToken({ id, entry });
395
466
  pending = {
396
467
  id,
397
468
  entry: { ...entry, pending: entry.pending ?? mintWriteToken() },
@@ -409,6 +480,7 @@ const rotate = async (
409
480
  pending,
410
481
  pending.entry.pending,
411
482
  terminal,
483
+ env,
412
484
  );
413
485
 
414
486
  const view = new URL(editUrl);
@@ -417,6 +489,148 @@ const rotate = async (
417
489
  terminal.out(" the old edit link no longer works");
418
490
  };
419
491
 
492
+ type Listed = {
493
+ id: string;
494
+ name: string;
495
+ api: string;
496
+ rev: number;
497
+ /** False for a view-link pull: the app keeps only the token's hash. */
498
+ editHere: boolean;
499
+ };
500
+
501
+ const byNameThenId = (a: Listed, b: Listed): number =>
502
+ a.name.localeCompare(b.name) || a.id.localeCompare(b.id);
503
+
504
+ /** Not spread: the entry carries write tokens, which must not be listed. */
505
+ const listed = (registry: CanvasRegistry): Listed[] =>
506
+ Object.entries(registry.canvases)
507
+ .map(([id, entry]) => ({
508
+ id,
509
+ name: entry.name,
510
+ api: entry.api,
511
+ rev: entry.rev,
512
+ editHere: entry.writeToken !== undefined,
513
+ }))
514
+ .sort(byNameThenId);
515
+
516
+ const previewName = (preview: CanvasPreview): string | undefined => {
517
+ switch (preview.type) {
518
+ case "drawn":
519
+ return preview.title;
520
+ case "not_drawn":
521
+ case "unreadable":
522
+ return undefined;
523
+ default:
524
+ return assertNever(preview, "Unhandled canvas preview");
525
+ }
526
+ };
527
+
528
+ /**
529
+ * The app owns names; only the registry knows whether the write token is
530
+ * here. Keyed by app too, since an id at another app is another canvas.
531
+ */
532
+ const merged = (
533
+ local: readonly Listed[],
534
+ api: string,
535
+ owned: readonly OwnedCanvas[],
536
+ ): Listed[] => {
537
+ const rows = new Map(
538
+ local.map((canvas) => [`${canvas.api}\n${canvas.id}`, canvas]),
539
+ );
540
+
541
+ for (const canvas of owned) {
542
+ const key = `${api}\n${canvas.id}`;
543
+ const here = rows.get(key);
544
+ rows.set(key, {
545
+ id: canvas.id,
546
+ name: previewName(canvas.preview) ?? here?.name ?? canvas.id,
547
+ api,
548
+ rev: canvas.rev,
549
+ editHere: here?.editHere ?? false,
550
+ });
551
+ }
552
+
553
+ return [...rows.values()].sort(byNameThenId);
554
+ };
555
+
556
+ const HEADINGS = ["ID", "NAME", "REV", "EDIT HERE"] as const;
557
+
558
+ const row = (cells: readonly string[], widths: readonly number[]): string =>
559
+ ` ${cells.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ")}`.trimEnd();
560
+
561
+ const tally = (canvases: readonly Listed[]): string => {
562
+ const count = `${canvases.length} ${canvases.length === 1 ? "canvas" : "canvases"}`;
563
+ const readOnly = canvases.filter((canvas) => !canvas.editHere).length;
564
+ return readOnly === 0
565
+ ? count
566
+ : `${count} · ${readOnly} this checkout cannot edit`;
567
+ };
568
+
569
+ const list = async (
570
+ args: readonly string[],
571
+ terminal: Terminal,
572
+ env: Record<string, string | undefined>,
573
+ ): Promise<void> => {
574
+ const { values, positionals } = parseOptions(args, {
575
+ json: { type: "boolean" },
576
+ remote: { type: "boolean" },
577
+ api: { type: "string" },
578
+ });
579
+ if (positionals.length > 0)
580
+ throw usageError(
581
+ `list takes no positional arguments, got ${positionals.join(" ")}`,
582
+ );
583
+
584
+ const remote = readBoolean(values.remote);
585
+
586
+ // Only the flag filters. $PR_LENS_API_URL is read only when asking an app,
587
+ // so a local listing neither hides canvases nor fails on a bad value.
588
+ const narrowed = readString(values.api, "api") !== undefined;
589
+ const api = readApi(values.api, remote || narrowed ? env : {});
590
+
591
+ const local = listed(await readRegistry()).filter(
592
+ (canvas) => !narrowed || canvas.api === api,
593
+ );
594
+
595
+ const canvases = remote
596
+ ? merged(
597
+ local,
598
+ api,
599
+ await listOwnedCanvases(api, await requireToken(env, api)),
600
+ )
601
+ : local;
602
+
603
+ if (readBoolean(values.json)) {
604
+ terminal.out(JSON.stringify({ canvases }, null, 2));
605
+ return;
606
+ }
607
+
608
+ if (canvases.length === 0) {
609
+ terminal.out(
610
+ remote
611
+ ? `no canvases at ${new URL(api).host} for this account, and none in ${REGISTRY_PATH}`
612
+ : `no canvases in ${REGISTRY_PATH} yet`,
613
+ );
614
+ terminal.out(" pr-lens canvas push mints one");
615
+ return;
616
+ }
617
+
618
+ const rows = canvases.map((canvas) => [
619
+ canvas.id,
620
+ canvas.name,
621
+ String(canvas.rev),
622
+ canvas.editHere ? "yes" : "no",
623
+ ]);
624
+ const widths = HEADINGS.map((heading, column) =>
625
+ Math.max(heading.length, ...rows.map((cells) => cells[column]?.length ?? 0)),
626
+ );
627
+
628
+ terminal.out(row(HEADINGS, widths));
629
+ for (const cells of rows) terminal.out(row(cells, widths));
630
+ terminal.out("");
631
+ terminal.out(` ${tally(canvases)}`);
632
+ };
633
+
420
634
  export const canvasCommand = async (
421
635
  args: readonly string[],
422
636
  terminal: Terminal,
@@ -424,15 +638,21 @@ export const canvasCommand = async (
424
638
  ): Promise<void> => {
425
639
  const [name, ...rest] = args;
426
640
  if (name === undefined)
427
- throw usageError("canvas needs a subcommand: push, pull, rotate or delete");
641
+ throw usageError(
642
+ "canvas needs a subcommand: list, push, pull, claim, rotate or delete",
643
+ );
428
644
  if (!isSubcommand(name))
429
645
  throw usageError(`unknown canvas subcommand ${JSON.stringify(name)}`);
430
646
 
431
647
  switch (name) {
648
+ case "list":
649
+ return list(rest, terminal, env);
432
650
  case "push":
433
651
  return push(rest, terminal, env);
434
652
  case "pull":
435
653
  return pull(rest, terminal, env);
654
+ case "claim":
655
+ return claimCommand(rest, terminal, env);
436
656
  case "delete":
437
657
  return deleteCommand(rest, terminal, env);
438
658
  case "rotate":
@@ -9,9 +9,12 @@ import { PrLensCliError, usageError } from "../errors.js";
9
9
  import { repositoryRoot } from "../git.js";
10
10
  import { writeJsonFile, writeTextFile } from "../io.js";
11
11
  import type { Terminal } from "../terminal.js";
12
+ import { slugify } from "../slug.js";
12
13
  import { prepareWorkspace, WORKSPACE_DIR } from "../workspace.js";
13
14
 
14
- const DEFAULT_OUT = WORKSPACE_DIR;
15
+ /** Same title, same directory, so a redraw updates its canvas instead of minting one. */
16
+ const defaultOut = (title: string): string => join(WORKSPACE_DIR, slugify(title));
17
+
15
18
  const MANIFEST = "manifest.json";
16
19
 
17
20
  /**
@@ -31,7 +34,8 @@ Draws the document as self-contained SVGs — one per drill-down section per
31
34
  lens, in light and dark — and writes the manifest describing them. A document
32
35
  with no sections gets one diagram per lens it declares.
33
36
 
34
- -o, --out <dir> where the SVGs and the manifest go (default ${DEFAULT_OUT}/)
37
+ -o, --out <dir> where the SVGs, the document and the manifest go
38
+ (default ${WORKSPACE_DIR}/<title>/, one directory per drawing)
35
39
  --theme <theme> light | dark | both (default both)
36
40
  --config <file> corrections to draw with (default the repository's, if any)
37
41
  --no-config ignore the repository's corrections`;
@@ -72,7 +76,7 @@ export const renderCommand = async (args: readonly string[], terminal: Terminal)
72
76
  });
73
77
 
74
78
  const graph = await readGraphDoc(expectOne(positionals, "one graph document to render"));
75
- const out = readString(values.out, "out") ?? DEFAULT_OUT;
79
+ const out = readString(values.out, "out") ?? defaultOut(graph.title);
76
80
 
77
81
  const configPath = readString(values.config, "config");
78
82
  const configured = readBoolean(values["no-config"])
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Per-machine state: the install id and the sign-in credential. Either one
3
+ * can act as this machine, so each store gets its own file and neither can
4
+ * reach the wrong `--api`.
5
+ */
6
+ import { join } from "node:path";
7
+
8
+ const DIRECTORY = "pr-lens";
9
+
10
+ /** Takes `env` from the caller so tests never write to the real home directory. */
11
+ export const configHome = (
12
+ env: Record<string, string | undefined>,
13
+ ): string | undefined => {
14
+ const xdg = env.XDG_CONFIG_HOME;
15
+ if (xdg) return xdg;
16
+
17
+ const home = env.HOME || env.USERPROFILE;
18
+ return home ? join(home, ".config") : undefined;
19
+ };
20
+
21
+ /**
22
+ * http and https only: other schemes have the origin "null", which would put
23
+ * unrelated stores in one file.
24
+ */
25
+ const fileFor = (api: string): string | undefined => {
26
+ try {
27
+ const { protocol, origin } = new URL(api);
28
+ if (protocol !== "http:" && protocol !== "https:") return undefined;
29
+ return `${encodeURIComponent(origin)}.json`;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ };
34
+
35
+ export const originPath = (
36
+ env: Record<string, string | undefined>,
37
+ folder: string,
38
+ api: string,
39
+ ): string | undefined => {
40
+ const home = configHome(env);
41
+ const file = fileFor(api);
42
+ return home === undefined || file === undefined
43
+ ? undefined
44
+ : join(home, DIRECTORY, folder, file);
45
+ };
package/src/errors.ts CHANGED
@@ -17,11 +17,15 @@ export type CliErrorCode =
17
17
  | "RENDER_FAILED"
18
18
  | "CANVAS_UNREGISTERED"
19
19
  | "CANVAS_UNKNOWN"
20
+ | "CANVAS_OWNED"
20
21
  | "CANVAS_CONFLICT"
21
22
  | "CANVAS_REJECTED"
22
23
  | "CANVAS_RATE_LIMITED"
23
24
  | "CANVAS_UNAVAILABLE"
24
- | "CANVAS_REGISTRY_EXPOSED";
25
+ | "CANVAS_REGISTRY_EXPOSED"
26
+ | "AUTH_REQUIRED"
27
+ | "MACHINE_REVOKED"
28
+ | "APP_UNAVAILABLE";
25
29
 
26
30
  export class PrLensCliError extends Error {
27
31
  readonly code: CliErrorCode;
package/src/install.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Names this machine to a store so a later sign-in can claim what it pushed.
3
+ *
4
+ * Kept per store, since whoever holds the id can have this machine's canvases
5
+ * attributed to them. Never put in a link, unlike a write token.
6
+ */
7
+ import { dirname } from "node:path";
8
+ import { randomBytes } from "node:crypto";
9
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
10
+
11
+ import { originPath } from "./config-home.js";
12
+
13
+ const INSTALLS = "installs";
14
+
15
+ const PREFIX = "prl_i_";
16
+
17
+ const INSTALL_ID = /^prl_i_[A-Za-z0-9_-]{22}$/;
18
+
19
+ export const installPath = (
20
+ env: Record<string, string | undefined>,
21
+ api: string,
22
+ ): string | undefined => originPath(env, INSTALLS, api);
23
+
24
+ const idIn = (text: string): string | undefined => {
25
+ const contents = ((): unknown => {
26
+ try {
27
+ return JSON.parse(text) as unknown;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ })();
32
+ if (typeof contents !== "object" || contents === null) return undefined;
33
+
34
+ const { installId } = contents as { installId?: unknown };
35
+ return typeof installId === "string" && INSTALL_ID.test(installId)
36
+ ? installId
37
+ : undefined;
38
+ };
39
+
40
+ /** Unreadable counts as absent: a machine with no id still pushes. */
41
+ const storedId = (path: string): Promise<string | undefined> =>
42
+ readFile(path, "utf8").then(idIn, () => undefined);
43
+
44
+ export const readStoredInstallId = async (
45
+ env: Record<string, string | undefined>,
46
+ api: string,
47
+ ): Promise<string | undefined> => {
48
+ const path = installPath(env, api);
49
+ return path === undefined ? undefined : storedId(path);
50
+ };
51
+
52
+ const write = async (path: string): Promise<string> => {
53
+ const minted = `${PREFIX}${randomBytes(16).toString("base64url")}`;
54
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
55
+ await writeFile(path, `${JSON.stringify({ installId: minted }, null, 2)}\n`, {
56
+ encoding: "utf8",
57
+ mode: 0o600,
58
+ flag: "wx",
59
+ });
60
+ return minted;
61
+ };
62
+
63
+ const alreadyThere = (error: unknown): boolean =>
64
+ typeof error === "object" &&
65
+ error !== null &&
66
+ "code" in error &&
67
+ error.code === "EEXIST";
68
+
69
+ /**
70
+ * Exclusive create settles two first runs without a lock: the loser reads the
71
+ * winner's id. Repairing a junk file can race under concurrency; accepted as rare.
72
+ */
73
+ const create = async (path: string): Promise<string | undefined> => {
74
+ try {
75
+ return await write(path);
76
+ } catch (error) {
77
+ if (!alreadyThere(error)) return undefined;
78
+
79
+ // One read, so a peer's repair cannot land between two.
80
+ const text = await readFile(path, "utf8").catch(() => undefined);
81
+
82
+ // An unreadable file may hold an id the app already has; never delete it.
83
+ if (text === undefined) return undefined;
84
+
85
+ const won = idIn(text);
86
+ if (won !== undefined) return won;
87
+
88
+ const cleared = await rm(path, { force: true }).then(
89
+ () => true,
90
+ () => false,
91
+ );
92
+ if (!cleared) return undefined;
93
+
94
+ return write(path).catch(() => storedId(path));
95
+ }
96
+ };
97
+
98
+ /** Failures answer undefined: attribution is never worth breaking a push. */
99
+ export const readInstallId = async (
100
+ env: Record<string, string | undefined>,
101
+ api: string,
102
+ ): Promise<string | undefined> => {
103
+ const path = installPath(env, api);
104
+ if (path === undefined) return undefined;
105
+
106
+ return (await storedId(path)) ?? (await create(path));
107
+ };