@coldtea/pr-lens-cli 0.1.3 → 0.2.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 +37 -7
- package/dist/canvas/api.d.ts +65 -0
- package/dist/canvas/api.d.ts.map +1 -0
- package/dist/canvas/api.js +175 -0
- package/dist/canvas/api.js.map +1 -0
- package/dist/canvas/registry.d.ts +45 -0
- package/dist/canvas/registry.d.ts.map +1 -0
- package/dist/canvas/registry.js +309 -0
- package/dist/canvas/registry.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/canvas.d.ts +4 -0
- package/dist/commands/canvas.d.ts.map +1 -0
- package/dist/commands/canvas.js +358 -0
- package/dist/commands/canvas.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/io.d.ts +7 -0
- package/dist/io.d.ts.map +1 -1
- package/dist/io.js +22 -1
- package/dist/io.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +8 -3
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
- package/src/canvas/api.ts +312 -0
- package/src/canvas/registry.ts +479 -0
- package/src/cli.ts +7 -1
- package/src/commands/canvas.ts +549 -0
- package/src/errors.ts +8 -1
- package/src/io.ts +23 -1
- package/src/version.ts +1 -1
- package/src/workspace.ts +9 -4
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { parseOptions, readString } from "../args.js";
|
|
3
|
+
import {
|
|
4
|
+
fetchCanvas,
|
|
5
|
+
mintCanvas,
|
|
6
|
+
pushCanvas,
|
|
7
|
+
rotateCanvas,
|
|
8
|
+
verifyWriteToken,
|
|
9
|
+
} from "../canvas/api.js";
|
|
10
|
+
import {
|
|
11
|
+
ensureRegistryHome,
|
|
12
|
+
findBySource,
|
|
13
|
+
findCanvas,
|
|
14
|
+
isCanvasId,
|
|
15
|
+
mintWriteToken,
|
|
16
|
+
onlyCanvas,
|
|
17
|
+
isReservedRegistryTarget,
|
|
18
|
+
readRegistry,
|
|
19
|
+
REGISTRY_PATH,
|
|
20
|
+
sourceKey,
|
|
21
|
+
updateRegistry,
|
|
22
|
+
withRegistryLock,
|
|
23
|
+
writeRegistry,
|
|
24
|
+
type CanvasEntry,
|
|
25
|
+
type CanvasRegistry,
|
|
26
|
+
type Registered,
|
|
27
|
+
} from "../canvas/registry.js";
|
|
28
|
+
import { readGraphDoc } from "../document.js";
|
|
29
|
+
import { PrLensCliError, usageError } from "../errors.js";
|
|
30
|
+
import { writeJsonFile } from "../io.js";
|
|
31
|
+
import type { Terminal } from "../terminal.js";
|
|
32
|
+
import { WORKSPACE_DIR } from "../workspace.js";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_API = "https://prlens.dev";
|
|
35
|
+
const API_ENV = "PR_LENS_API_URL";
|
|
36
|
+
const DEFAULT_SOURCE = `${WORKSPACE_DIR}/drawn.graph.json`;
|
|
37
|
+
const DEFAULT_OUT = `${WORKSPACE_DIR}/graph.json`;
|
|
38
|
+
|
|
39
|
+
const SUBCOMMANDS = ["push", "pull", "rotate"] as const;
|
|
40
|
+
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
41
|
+
|
|
42
|
+
const isSubcommand = (value: string): value is Subcommand =>
|
|
43
|
+
SUBCOMMANDS.some((subcommand) => subcommand === value);
|
|
44
|
+
|
|
45
|
+
export const USAGE = `pr-lens canvas <push | pull | rotate> [options]
|
|
46
|
+
|
|
47
|
+
Keeps a graph document on the PR Lens app as a canvas: a page anyone with the
|
|
48
|
+
link can read, and an SVG a README can embed. The write token lands in
|
|
49
|
+
${REGISTRY_PATH}, which git ignores; the edit link carries the same token
|
|
50
|
+
in its fragment, so share the view link and keep the edit link to yourself.
|
|
51
|
+
|
|
52
|
+
pr-lens canvas push [graph.json] send the document (default ${DEFAULT_SOURCE})
|
|
53
|
+
--canvas <id|name> which canvas (default the one this document
|
|
54
|
+
was pushed to before, else a new one)
|
|
55
|
+
--name <name> what to call a new canvas (default the document's title)
|
|
56
|
+
|
|
57
|
+
pr-lens canvas pull [url|id] fetch the document (default the checkout's only canvas)
|
|
58
|
+
--canvas <id|name> which canvas, when no url or id is given
|
|
59
|
+
-o, --out <file> where to write it (default ${DEFAULT_OUT})
|
|
60
|
+
|
|
61
|
+
pr-lens canvas rotate mint a new write token; the old edit link stops working
|
|
62
|
+
--canvas <id|name> which canvas (default the checkout's only canvas)
|
|
63
|
+
|
|
64
|
+
--api <url> the PR Lens app (default $${API_ENV}, else ${DEFAULT_API})`;
|
|
65
|
+
|
|
66
|
+
const readApi = (
|
|
67
|
+
value: unknown,
|
|
68
|
+
env: Record<string, string | undefined>,
|
|
69
|
+
): string => {
|
|
70
|
+
const api = readString(value, "api") ?? env[API_ENV] ?? DEFAULT_API;
|
|
71
|
+
try {
|
|
72
|
+
new URL(api);
|
|
73
|
+
} catch {
|
|
74
|
+
throw usageError(`--api needs a URL, got ${JSON.stringify(api)}`);
|
|
75
|
+
}
|
|
76
|
+
return api.replace(/\/+$/, "");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type CanvasRef = {
|
|
80
|
+
id: string;
|
|
81
|
+
origin: string | undefined;
|
|
82
|
+
writeToken: string | undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_-]{22}$/;
|
|
86
|
+
|
|
87
|
+
/** Pulling an edit link is how a checkout that never pushed a canvas gets its token. */
|
|
88
|
+
const readCanvasRef = (value: string): CanvasRef => {
|
|
89
|
+
if (isCanvasId(value))
|
|
90
|
+
return { id: value, origin: undefined, writeToken: undefined };
|
|
91
|
+
|
|
92
|
+
const url = (() => {
|
|
93
|
+
try {
|
|
94
|
+
return new URL(value);
|
|
95
|
+
} catch {
|
|
96
|
+
throw usageError(
|
|
97
|
+
`expected a canvas id or a canvas URL, got ${JSON.stringify(value)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
|
|
102
|
+
const [, c, last, ...deeper] = url.pathname.split("/");
|
|
103
|
+
const id = last?.replace(/\.svg$/, "");
|
|
104
|
+
if (c !== "c" || id === undefined || deeper.length > 0 || !isCanvasId(id))
|
|
105
|
+
throw usageError(`${value} is not a canvas URL`, "expected {app}/c/{id}");
|
|
106
|
+
|
|
107
|
+
const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
|
|
108
|
+
const writeToken = fragment.get("w") ?? undefined;
|
|
109
|
+
if (writeToken !== undefined && !TOKEN_SHAPE.test(writeToken))
|
|
110
|
+
throw usageError(
|
|
111
|
+
`${value} carries something after #w= that is not a write token`,
|
|
112
|
+
"an edit link ends in #w= and 22 characters",
|
|
113
|
+
);
|
|
114
|
+
return { id, origin: url.origin, writeToken };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Another app's 404 says nothing about this entry, so it must not change it. */
|
|
118
|
+
const requireSameApi = (api: string, { id, entry }: Registered): void => {
|
|
119
|
+
if (entry.api === api) return;
|
|
120
|
+
throw new PrLensCliError(
|
|
121
|
+
"CANVAS_UNREGISTERED",
|
|
122
|
+
`${id} is registered against ${entry.api}, not ${api}`,
|
|
123
|
+
`pass --api ${entry.api}, or push the document without --canvas to mint a canvas here`,
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const requireWriteToken = ({ id, entry }: Registered): string => {
|
|
128
|
+
if (entry.writeToken !== undefined) return entry.writeToken;
|
|
129
|
+
throw new PrLensCliError(
|
|
130
|
+
"CANVAS_UNREGISTERED",
|
|
131
|
+
`this checkout can read ${id} but holds no write token for it`,
|
|
132
|
+
"pull its edit link, the one with #w= at the end, and the token comes with it",
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const countDiagrams = (count: number): string =>
|
|
137
|
+
`${count} ${count === 1 ? "diagram" : "diagrams"}`;
|
|
138
|
+
|
|
139
|
+
const unfinishedRotation = (error: PrLensCliError): PrLensCliError =>
|
|
140
|
+
new PrLensCliError(
|
|
141
|
+
error.code,
|
|
142
|
+
`${error.message}; the rotation is not finished`,
|
|
143
|
+
[
|
|
144
|
+
error.details,
|
|
145
|
+
"run pr-lens canvas rotate again to finish it: the new token is kept until the app confirms it",
|
|
146
|
+
]
|
|
147
|
+
.filter((line) => line !== undefined && line !== "")
|
|
148
|
+
.join("\n"),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
/** Asking again with the same pair is safe: the app answers "rotated" once the token is on record. */
|
|
152
|
+
const settleRotation = async (
|
|
153
|
+
api: string,
|
|
154
|
+
{ id, entry }: Registered,
|
|
155
|
+
nextToken: string,
|
|
156
|
+
terminal: Terminal,
|
|
157
|
+
): Promise<{ registered: Registered; editUrl: string }> => {
|
|
158
|
+
const rotated = await rotateCanvas(
|
|
159
|
+
api,
|
|
160
|
+
id,
|
|
161
|
+
requireWriteToken({ id, entry }),
|
|
162
|
+
nextToken,
|
|
163
|
+
).catch(async (error: unknown) => {
|
|
164
|
+
if (!(error instanceof PrLensCliError)) throw error;
|
|
165
|
+
if (error.code !== "CANVAS_UNKNOWN") throw unfinishedRotation(error);
|
|
166
|
+
|
|
167
|
+
// Final: a pending token that was current would have been answered
|
|
168
|
+
// "rotated". Drop it now, or a token imported later would carry it out.
|
|
169
|
+
await updateRegistry((registry) => {
|
|
170
|
+
const current = registry.canvases[id];
|
|
171
|
+
if (
|
|
172
|
+
current === undefined ||
|
|
173
|
+
current.pending !== nextToken ||
|
|
174
|
+
current.api !== api
|
|
175
|
+
)
|
|
176
|
+
return;
|
|
177
|
+
registry.canvases[id] = { ...current, pending: undefined };
|
|
178
|
+
}, terminal);
|
|
179
|
+
throw new PrLensCliError(
|
|
180
|
+
error.code,
|
|
181
|
+
`${error.message}; the pending rotation was dropped`,
|
|
182
|
+
error.details,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// A different token pending by now belongs to a later rotation.
|
|
187
|
+
await updateRegistry((registry) => {
|
|
188
|
+
const current = registry.canvases[id];
|
|
189
|
+
if (
|
|
190
|
+
current === undefined ||
|
|
191
|
+
current.pending !== nextToken ||
|
|
192
|
+
current.api !== api
|
|
193
|
+
)
|
|
194
|
+
return;
|
|
195
|
+
registry.canvases[id] = {
|
|
196
|
+
...current,
|
|
197
|
+
writeToken: nextToken,
|
|
198
|
+
pending: undefined,
|
|
199
|
+
};
|
|
200
|
+
}, terminal);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
registered: {
|
|
204
|
+
id,
|
|
205
|
+
entry: { ...entry, writeToken: nextToken, pending: undefined },
|
|
206
|
+
},
|
|
207
|
+
editUrl: rotated.editUrl,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
type Recorded = "imported" | "kept" | "overtaken" | "refused" | "elsewhere";
|
|
212
|
+
|
|
213
|
+
type PullRecord = {
|
|
214
|
+
api: string;
|
|
215
|
+
id: string;
|
|
216
|
+
/** Undefined for a canvas minted and never pushed to. */
|
|
217
|
+
fetched: { rev: number; title: string } | undefined;
|
|
218
|
+
out: string;
|
|
219
|
+
writeToken: string | undefined;
|
|
220
|
+
/** The stored token when the proof was made; if it changed since, the proof is stale. */
|
|
221
|
+
seenToken: string | undefined;
|
|
222
|
+
proven: boolean;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Decided under the lock. A token that changes hands drops a pending
|
|
227
|
+
* rotation, which was the old holder's business; the same token keeps it.
|
|
228
|
+
*/
|
|
229
|
+
const recordPull = (current: CanvasRegistry, pull: PullRecord): Recorded => {
|
|
230
|
+
const entry = current.canvases[pull.id];
|
|
231
|
+
if (entry !== undefined && entry.api !== pull.api) return "elsewhere";
|
|
232
|
+
|
|
233
|
+
const untouched = entry?.writeToken === pull.seenToken;
|
|
234
|
+
const imports =
|
|
235
|
+
pull.proven && untouched && pull.writeToken !== entry?.writeToken;
|
|
236
|
+
|
|
237
|
+
const kept = imports ? pull.writeToken : entry?.writeToken;
|
|
238
|
+
const pending = imports ? undefined : entry?.pending;
|
|
239
|
+
|
|
240
|
+
current.canvases[pull.id] = {
|
|
241
|
+
name: entry?.name ?? pull.fetched?.title ?? pull.id,
|
|
242
|
+
source:
|
|
243
|
+
entry?.source ??
|
|
244
|
+
(pull.fetched === undefined ? DEFAULT_SOURCE : sourceKey(pull.out)),
|
|
245
|
+
api: pull.api,
|
|
246
|
+
...(pending === undefined ? {} : { pending }),
|
|
247
|
+
...(kept === undefined ? {} : { writeToken: kept }),
|
|
248
|
+
rev: pull.fetched?.rev ?? entry?.rev ?? 0,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return imports
|
|
252
|
+
? "imported"
|
|
253
|
+
: pull.proven && !untouched
|
|
254
|
+
? "overtaken"
|
|
255
|
+
: pull.writeToken !== undefined && !pull.proven
|
|
256
|
+
? "refused"
|
|
257
|
+
: "kept";
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const tellRecorded = (
|
|
261
|
+
recorded: Recorded,
|
|
262
|
+
id: string,
|
|
263
|
+
terminal: Terminal,
|
|
264
|
+
): void => {
|
|
265
|
+
switch (recorded) {
|
|
266
|
+
case "imported":
|
|
267
|
+
terminal.out(` the edit link's token is now in ${REGISTRY_PATH}`);
|
|
268
|
+
return;
|
|
269
|
+
case "refused":
|
|
270
|
+
terminal.err(
|
|
271
|
+
` the edit link's token no longer opens ${id}; nothing was recorded for it`,
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
case "overtaken":
|
|
275
|
+
terminal.err(
|
|
276
|
+
` ${REGISTRY_PATH} changed while the edit link was being checked; its token was not recorded`,
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
case "elsewhere":
|
|
280
|
+
terminal.err(
|
|
281
|
+
` ${id} is registered against another app in ${REGISTRY_PATH}; that entry was left as it is`,
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
case "kept":
|
|
285
|
+
return;
|
|
286
|
+
default:
|
|
287
|
+
return assertNever(recorded, "Unhandled record outcome");
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const push = async (
|
|
292
|
+
args: readonly string[],
|
|
293
|
+
terminal: Terminal,
|
|
294
|
+
env: Record<string, string | undefined>,
|
|
295
|
+
): Promise<void> => {
|
|
296
|
+
const { values, positionals } = parseOptions(args, {
|
|
297
|
+
canvas: { type: "string" },
|
|
298
|
+
name: { type: "string" },
|
|
299
|
+
api: { type: "string" },
|
|
300
|
+
});
|
|
301
|
+
if (positionals.length > 1)
|
|
302
|
+
throw usageError(
|
|
303
|
+
`push takes one graph document, got ${positionals.length}`,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const source = positionals[0] ?? DEFAULT_SOURCE;
|
|
307
|
+
const document = await readGraphDoc(source);
|
|
308
|
+
const api = readApi(values.api, env);
|
|
309
|
+
await ensureRegistryHome(terminal);
|
|
310
|
+
const registry = await readRegistry();
|
|
311
|
+
|
|
312
|
+
const ref = readString(values.canvas, "canvas");
|
|
313
|
+
const known =
|
|
314
|
+
ref === undefined
|
|
315
|
+
? findBySource(registry, source)
|
|
316
|
+
: findCanvas(registry, ref);
|
|
317
|
+
|
|
318
|
+
const registered: Registered =
|
|
319
|
+
known ??
|
|
320
|
+
// Under the lock, so a racing push of the same file finds this entry
|
|
321
|
+
// instead of minting its own.
|
|
322
|
+
(await withRegistryLock(async () => {
|
|
323
|
+
const current = await readRegistry();
|
|
324
|
+
const meanwhile =
|
|
325
|
+
ref === undefined ? findBySource(current, source) : undefined;
|
|
326
|
+
if (meanwhile !== undefined) return meanwhile;
|
|
327
|
+
|
|
328
|
+
const minted = await mintCanvas(api);
|
|
329
|
+
const entry: CanvasEntry = {
|
|
330
|
+
name: readString(values.name, "name") ?? document.title,
|
|
331
|
+
source: sourceKey(source),
|
|
332
|
+
api,
|
|
333
|
+
writeToken: minted.writeToken,
|
|
334
|
+
rev: minted.rev,
|
|
335
|
+
};
|
|
336
|
+
current.canvases[minted.id] = entry;
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await writeRegistry(current, terminal);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
// The app hands the token out once; the terminal is the only place left for it.
|
|
342
|
+
if (!(error instanceof PrLensCliError)) throw error;
|
|
343
|
+
throw new PrLensCliError(
|
|
344
|
+
error.code,
|
|
345
|
+
`${error.message}; the canvas was minted and its token is not saved`,
|
|
346
|
+
[
|
|
347
|
+
`keep this edit link, it is the only copy: ${minted.editUrl}`,
|
|
348
|
+
"pull it once the registry can be written, and the token is recorded",
|
|
349
|
+
error.details ?? "",
|
|
350
|
+
]
|
|
351
|
+
.filter((line) => line !== "")
|
|
352
|
+
.join("\n"),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return { id: minted.id, entry };
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
requireSameApi(api, registered);
|
|
359
|
+
const pending = registered.entry.pending;
|
|
360
|
+
const target =
|
|
361
|
+
pending === undefined
|
|
362
|
+
? registered
|
|
363
|
+
: (await settleRotation(api, registered, pending, terminal)).registered;
|
|
364
|
+
|
|
365
|
+
const pushed = await pushCanvas(
|
|
366
|
+
api,
|
|
367
|
+
target.id,
|
|
368
|
+
requireWriteToken(target),
|
|
369
|
+
target.entry.rev,
|
|
370
|
+
document,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await updateRegistry((current) => {
|
|
374
|
+
current.canvases[target.id] = {
|
|
375
|
+
...(current.canvases[target.id] ?? target.entry),
|
|
376
|
+
source: sourceKey(source),
|
|
377
|
+
rev: pushed.rev,
|
|
378
|
+
};
|
|
379
|
+
}, terminal);
|
|
380
|
+
|
|
381
|
+
terminal.out(
|
|
382
|
+
`✓ ${pushed.viewUrl} — rev ${pushed.rev} · ${countDiagrams(pushed.tiles.length)}`,
|
|
383
|
+
);
|
|
384
|
+
terminal.out(` edit link, keep it to yourself: ${pushed.editUrl}`);
|
|
385
|
+
terminal.out(` README embed: ${pushed.embedUrl}`);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const pull = async (
|
|
389
|
+
args: readonly string[],
|
|
390
|
+
terminal: Terminal,
|
|
391
|
+
env: Record<string, string | undefined>,
|
|
392
|
+
): Promise<void> => {
|
|
393
|
+
const { values, positionals } = parseOptions(args, {
|
|
394
|
+
canvas: { type: "string" },
|
|
395
|
+
out: { type: "string", short: "o" },
|
|
396
|
+
api: { type: "string" },
|
|
397
|
+
});
|
|
398
|
+
if (positionals.length > 1)
|
|
399
|
+
throw usageError(
|
|
400
|
+
`pull takes one canvas url or id, got ${positionals.length}`,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const registry = await readRegistry();
|
|
404
|
+
const ref = readString(values.canvas, "canvas");
|
|
405
|
+
const [positional] = positionals;
|
|
406
|
+
|
|
407
|
+
const { id, origin, writeToken } =
|
|
408
|
+
positional !== undefined
|
|
409
|
+
? readCanvasRef(positional)
|
|
410
|
+
: {
|
|
411
|
+
...(ref === undefined
|
|
412
|
+
? onlyCanvas(registry)
|
|
413
|
+
: findCanvas(registry, ref)),
|
|
414
|
+
origin: undefined,
|
|
415
|
+
writeToken: undefined,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// A pasted link says where it lives; --api still wins.
|
|
419
|
+
const explicit = readString(values.api, "api");
|
|
420
|
+
const api =
|
|
421
|
+
explicit === undefined && origin !== undefined
|
|
422
|
+
? origin
|
|
423
|
+
: readApi(explicit, env);
|
|
424
|
+
const out = readString(values.out, "out") ?? DEFAULT_OUT;
|
|
425
|
+
if (await isReservedRegistryTarget(out))
|
|
426
|
+
throw usageError(
|
|
427
|
+
`${out} is where the write tokens live; a document cannot be written there`,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// Proven before the fetch: an old bookmark must not replace the token
|
|
431
|
+
// that works, and an unpushed canvas has nothing to fetch yet.
|
|
432
|
+
const seenToken = registry.canvases[id]?.writeToken;
|
|
433
|
+
const proven =
|
|
434
|
+
writeToken !== undefined && (await verifyWriteToken(api, id, writeToken));
|
|
435
|
+
|
|
436
|
+
const fetched = await fetchCanvas(api, id).catch((error: unknown) => {
|
|
437
|
+
// Minted and never pushed: nothing to show, but a proven token is worth recording.
|
|
438
|
+
if (
|
|
439
|
+
error instanceof PrLensCliError &&
|
|
440
|
+
error.code === "CANVAS_UNKNOWN" &&
|
|
441
|
+
proven
|
|
442
|
+
)
|
|
443
|
+
return undefined;
|
|
444
|
+
throw error;
|
|
445
|
+
});
|
|
446
|
+
if (fetched !== undefined) await writeJsonFile(out, fetched.document);
|
|
447
|
+
|
|
448
|
+
const outcome: { recorded: Recorded } = { recorded: "kept" };
|
|
449
|
+
await updateRegistry((current) => {
|
|
450
|
+
outcome.recorded = recordPull(current, {
|
|
451
|
+
api,
|
|
452
|
+
id,
|
|
453
|
+
fetched:
|
|
454
|
+
fetched === undefined
|
|
455
|
+
? undefined
|
|
456
|
+
: { rev: fetched.rev, title: fetched.document.title },
|
|
457
|
+
out,
|
|
458
|
+
writeToken,
|
|
459
|
+
seenToken,
|
|
460
|
+
proven,
|
|
461
|
+
});
|
|
462
|
+
}, terminal);
|
|
463
|
+
|
|
464
|
+
terminal.out(
|
|
465
|
+
fetched === undefined
|
|
466
|
+
? `✓ ${id} has nothing pushed to it yet`
|
|
467
|
+
: `✓ ${out} — rev ${fetched.rev} of ${fetched.viewUrl}`,
|
|
468
|
+
);
|
|
469
|
+
tellRecorded(outcome.recorded, id, terminal);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const rotate = async (
|
|
473
|
+
args: readonly string[],
|
|
474
|
+
terminal: Terminal,
|
|
475
|
+
env: Record<string, string | undefined>,
|
|
476
|
+
): Promise<void> => {
|
|
477
|
+
const { values, positionals } = parseOptions(args, {
|
|
478
|
+
canvas: { type: "string" },
|
|
479
|
+
api: { type: "string" },
|
|
480
|
+
});
|
|
481
|
+
if (positionals.length > 0)
|
|
482
|
+
throw usageError(
|
|
483
|
+
`rotate takes no positional arguments, got ${positionals.join(" ")}`,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const api = readApi(values.api, env);
|
|
487
|
+
await ensureRegistryHome(terminal);
|
|
488
|
+
const registry = await readRegistry();
|
|
489
|
+
const ref = readString(values.canvas, "canvas");
|
|
490
|
+
const { id } =
|
|
491
|
+
ref === undefined ? onlyCanvas(registry) : findCanvas(registry, ref);
|
|
492
|
+
|
|
493
|
+
// Saved before the request, so a lost answer cannot lose it; chosen under
|
|
494
|
+
// the lock, so two rotations at once finish the same one.
|
|
495
|
+
let pending: Registered | undefined;
|
|
496
|
+
await updateRegistry((current) => {
|
|
497
|
+
const entry = current.canvases[id];
|
|
498
|
+
if (entry === undefined) return;
|
|
499
|
+
// Without a token, a pending rotation would be carried out by whatever
|
|
500
|
+
// token arrives next, retiring the very link that brought it.
|
|
501
|
+
requireSameApi(api, { id, entry });
|
|
502
|
+
requireWriteToken({ id, entry });
|
|
503
|
+
pending = {
|
|
504
|
+
id,
|
|
505
|
+
entry: { ...entry, pending: entry.pending ?? mintWriteToken() },
|
|
506
|
+
};
|
|
507
|
+
current.canvases[id] = pending.entry;
|
|
508
|
+
}, terminal);
|
|
509
|
+
if (pending === undefined || pending.entry.pending === undefined)
|
|
510
|
+
throw new PrLensCliError(
|
|
511
|
+
"CANVAS_UNREGISTERED",
|
|
512
|
+
`${id} is no longer in ${REGISTRY_PATH}`,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const { editUrl } = await settleRotation(
|
|
516
|
+
api,
|
|
517
|
+
pending,
|
|
518
|
+
pending.entry.pending,
|
|
519
|
+
terminal,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const view = new URL(editUrl);
|
|
523
|
+
view.hash = "";
|
|
524
|
+
terminal.out(`✓ new edit link for ${view.href}: ${editUrl}`);
|
|
525
|
+
terminal.out(" the old edit link no longer works");
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
export const canvasCommand = async (
|
|
529
|
+
args: readonly string[],
|
|
530
|
+
terminal: Terminal,
|
|
531
|
+
env: Record<string, string | undefined>,
|
|
532
|
+
): Promise<void> => {
|
|
533
|
+
const [name, ...rest] = args;
|
|
534
|
+
if (name === undefined)
|
|
535
|
+
throw usageError("canvas needs a subcommand: push, pull or rotate");
|
|
536
|
+
if (!isSubcommand(name))
|
|
537
|
+
throw usageError(`unknown canvas subcommand ${JSON.stringify(name)}`);
|
|
538
|
+
|
|
539
|
+
switch (name) {
|
|
540
|
+
case "push":
|
|
541
|
+
return push(rest, terminal, env);
|
|
542
|
+
case "pull":
|
|
543
|
+
return pull(rest, terminal, env);
|
|
544
|
+
case "rotate":
|
|
545
|
+
return rotate(rest, terminal, env);
|
|
546
|
+
default:
|
|
547
|
+
return assertNever(name, "Unhandled canvas subcommand");
|
|
548
|
+
}
|
|
549
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -14,7 +14,14 @@ export type CliErrorCode =
|
|
|
14
14
|
| "MISSING_API_KEY"
|
|
15
15
|
| "PROVIDER_FAILED"
|
|
16
16
|
| "MODEL_OUTPUT_INVALID"
|
|
17
|
-
| "RENDER_FAILED"
|
|
17
|
+
| "RENDER_FAILED"
|
|
18
|
+
| "CANVAS_UNREGISTERED"
|
|
19
|
+
| "CANVAS_UNKNOWN"
|
|
20
|
+
| "CANVAS_CONFLICT"
|
|
21
|
+
| "CANVAS_REJECTED"
|
|
22
|
+
| "CANVAS_RATE_LIMITED"
|
|
23
|
+
| "CANVAS_UNAVAILABLE"
|
|
24
|
+
| "CANVAS_REGISTRY_EXPOSED";
|
|
18
25
|
|
|
19
26
|
export class PrLensCliError extends Error {
|
|
20
27
|
readonly code: CliErrorCode;
|
package/src/io.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { PrLensCliError } from "./errors.js";
|
|
4
4
|
|
|
@@ -36,3 +36,25 @@ export const writeTextFile = async (path: string, contents: string): Promise<str
|
|
|
36
36
|
/** One trailing newline, so written documents behave in a diff and in a shell. */
|
|
37
37
|
export const writeJsonFile = (path: string, value: unknown): Promise<string> =>
|
|
38
38
|
writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A file that holds a secret and nothing else can rebuild: readable by its
|
|
42
|
+
* owner alone, and replaced in one step so an interrupted write leaves the
|
|
43
|
+
* old copy rather than half of a new one.
|
|
44
|
+
*/
|
|
45
|
+
export const secretStagingPath = (path: string): string => `${resolve(path)}.${process.pid}.tmp`;
|
|
46
|
+
|
|
47
|
+
export const writeSecretJsonFile = async (path: string, value: unknown): Promise<string> => {
|
|
48
|
+
const absolute = resolve(path);
|
|
49
|
+
const staging = secretStagingPath(path);
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
52
|
+
await writeFile(staging, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
53
|
+
await rename(staging, absolute);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Never leave a secret lying about under a temporary name.
|
|
56
|
+
await unlink(staging).catch(() => undefined);
|
|
57
|
+
throw new PrLensCliError("UNREADABLE_FILE", `cannot write ${path}`, describe(error));
|
|
58
|
+
}
|
|
59
|
+
return absolute;
|
|
60
|
+
};
|
package/src/version.ts
CHANGED
package/src/workspace.ts
CHANGED
|
@@ -160,13 +160,18 @@ const README = `# .pr-lens
|
|
|
160
160
|
PR Lens writes its previews here: the diagrams as light and dark SVGs, the
|
|
161
161
|
document they were drawn from, and the manifest describing them.
|
|
162
162
|
|
|
163
|
-
None of
|
|
163
|
+
None of that belongs in a commit. Those files are rebuilt from the diff by
|
|
164
164
|
\`pr-lens analyze\` and \`pr-lens render\`, so a stale copy in the history is
|
|
165
165
|
worth less than nothing — it is a diagram of a pull request somebody already
|
|
166
166
|
merged. What readers are meant to see is the comment on the pull request, or
|
|
167
|
-
the share page it links to.
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
the share page it links to. Delete them whenever you like; nothing reads them
|
|
168
|
+
back.
|
|
169
|
+
|
|
170
|
+
\`canvas.json\` is the exception. It holds the write token for every canvas
|
|
171
|
+
this checkout has pushed with \`pr-lens canvas push\`, and nothing can rebuild
|
|
172
|
+
it. Without it the canvases stay readable by everyone, but pushing to them
|
|
173
|
+
again needs the edit link you were given. Keep it out of commits and out of
|
|
174
|
+
other people's hands.
|
|
170
175
|
`;
|
|
171
176
|
|
|
172
177
|
/**
|