@coldtea/pr-lens-cli 0.1.2 → 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.
@@ -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/prompt.ts CHANGED
@@ -31,7 +31,11 @@ export const SYSTEM_PROMPT = [
31
31
  "- Give the document a title a reviewer would recognise, and a summary that answers 'what does this change do?' in a short paragraph.",
32
32
  "- Attach file refs to nodes, edges and steps wherever the diff shows where they live: they become the permalinks a reader clicks.",
33
33
  "- Add a data flow only when the change actually has an ordered sequence worth animating. One good flow beats three thin ones.",
34
- "- Build the drill-down tree: a view over the whole document, and a focused view for each part of the change that deserves its own section. Views are what a reader expands in the comment, so a document without them renders as one picture and nothing else.",
34
+ "- Choose architecture views as a C4-inspired decision tree, not a quota. A small change may need only one useful view.",
35
+ "- Start at the highest useful level: use system context when the change affects a user, an external system or a system boundary; otherwise start with containers. Container views cover affected applications, services, jobs, data stores and runtimes. Add a component child only when an affected container's internals matter. Do not add code-level views by default.",
36
+ "- Each architecture child moves down one level and materially narrows the scope. Skip empty, repetitive or speculative levels, and never create views with substantially the same nodes and edges. Infer boundaries from behavior and dependencies, not folder names alone.",
37
+ "- Keep data-flow views as separate roots, outside the architecture hierarchy. Set the highest useful architecture view `defaultOpen: true`; lower architecture views should normally stay collapsed.",
38
+ "- Views are expandable comment sections. Without views, each requested lens renders once.",
35
39
  "- Stat chips carry the numbers that make the change legible, e.g. label 'Postmark calls' value '500x fewer'. Leave out files changed and line counts — those are filled in for you.",
36
40
  "",
37
41
  "Answer with the JSON document alone. No prose, no markdown fence.",
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /** Recorded on every document this CLI produces, as the generator's version. */
2
- export const CLI_VERSION = "0.1.2";
2
+ export const CLI_VERSION = "0.2.0";
3
3
 
4
4
  export const GENERATOR_NAME = "pr-lens-cli";
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 it belongs in a commit. Every file is rebuilt from the diff by
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
- Delete the directory whenever you like. Nothing reads it back.
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
  /**