@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.
- package/README.md +31 -3
- package/dist/account.d.ts +76 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +211 -0
- package/dist/account.js.map +1 -0
- package/dist/auth.d.ts +40 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +121 -0
- package/dist/auth.js.map +1 -0
- package/dist/canvas/api.d.ts +35 -1
- package/dist/canvas/api.d.ts.map +1 -1
- package/dist/canvas/api.js +57 -1
- package/dist/canvas/api.js.map +1 -1
- package/dist/canvas/claim.d.ts +3 -0
- package/dist/canvas/claim.d.ts.map +1 -0
- package/dist/canvas/claim.js +84 -0
- package/dist/canvas/claim.js.map +1 -0
- package/dist/canvas/delete.js +3 -3
- package/dist/canvas/delete.js.map +1 -1
- package/dist/canvas/registry.d.ts +8 -2
- package/dist/canvas/registry.d.ts.map +1 -1
- package/dist/canvas/registry.js +15 -2
- package/dist/canvas/registry.js.map +1 -1
- package/dist/canvas/write.d.ts +11 -2
- package/dist/canvas/write.d.ts.map +1 -1
- package/dist/canvas/write.js +27 -8
- package/dist/canvas/write.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +369 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/canvas.d.ts.map +1 -1
- package/dist/commands/canvas.js +162 -21
- package/dist/commands/canvas.js.map +1 -1
- package/dist/commands/render.d.ts +1 -1
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +6 -3
- package/dist/commands/render.js.map +1 -1
- package/dist/config-home.d.ts +4 -0
- package/dist/config-home.d.ts.map +1 -0
- package/dist/config-home.js +38 -0
- package/dist/config-home.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/install.d.ts +5 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +83 -0
- package/dist/install.js.map +1 -0
- package/dist/skill-content.generated.d.ts +1 -1
- package/dist/skill-content.generated.d.ts.map +1 -1
- package/dist/skill-content.generated.js +1 -1
- package/dist/skill-content.generated.js.map +1 -1
- package/dist/slug.d.ts +7 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +23 -0
- package/dist/slug.js.map +1 -0
- package/dist/terminal.d.ts +5 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +7 -0
- package/dist/terminal.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +26 -4
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
- package/src/account.ts +302 -0
- package/src/auth.ts +183 -0
- package/src/canvas/api.ts +114 -2
- package/src/canvas/claim.ts +127 -0
- package/src/canvas/delete.ts +3 -3
- package/src/canvas/registry.ts +24 -2
- package/src/canvas/write.ts +37 -6
- package/src/cli.ts +8 -2
- package/src/commands/auth.ts +560 -0
- package/src/commands/canvas.ts +238 -18
- package/src/commands/render.ts +7 -3
- package/src/config-home.ts +45 -0
- package/src/errors.ts +5 -1
- package/src/install.ts +107 -0
- package/src/skill-content.generated.ts +1 -1
- package/src/slug.ts +26 -0
- package/src/terminal.ts +14 -0
- package/src/version.ts +1 -1
- package/src/workspace.ts +31 -4
package/src/commands/canvas.ts
CHANGED
|
@@ -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
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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] ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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":
|
package/src/commands/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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") ??
|
|
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
|
+
};
|