cawdev-cli 1.2.0 → 1.2.2

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,214 @@
1
+ // R605 — the screenshots a SHOW stage took, on this machine: where a copy is
2
+ // kept, how it is opened, and how a person's comments become a decision.
3
+ //
4
+ // Zero dependencies, and pure wherever it can be. The one thing here that
5
+ // touches the disk is `localCopy`, and it is the ONLY writer of the cache:
6
+ // the daemon never writes under `~/.cawdev/shots`, because a session could
7
+ // plant `<runId>` there as a symlink and the daemon would then write files
8
+ // with names the session chose wherever the link points. The terminal
9
+ // writes names it derives itself — the checksum and an extension taken from
10
+ // the platform's content type — and refuses a link at any level.
11
+
12
+ import { createHash, randomUUID } from 'node:crypto';
13
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { stripAnsi } from '../lib/ansi.mjs';
17
+
18
+ const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg' };
19
+
20
+ /** Where copies are kept. */
21
+ export function shotsDir(env = process.env) {
22
+ return env.CAWDEV_SHOTS_DIR ?? join(homedir(), '.cawdev', 'shots');
23
+ }
24
+
25
+ /** A picture the OS viewer may be handed. HTML is not: it opens on the platform. */
26
+ export function isImage(view) {
27
+ return Object.hasOwn(EXTENSIONS, view?.contentType ?? '');
28
+ }
29
+
30
+ /**
31
+ * Where a copy of this screenshot lives, or null for HTML. The name is the
32
+ * checksum and the extension comes from the content type — never from `name`,
33
+ * which a session chose.
34
+ */
35
+ export function cachedPath(dir, view) {
36
+ if (!isImage(view) || !/^[0-9a-f]{64}$/i.test(view.sha256 ?? '')) return null;
37
+ // Both are path parts: nothing but a plain id and a whole number may become one.
38
+ if (!/^[A-Za-z0-9_-]+$/.test(String(view.runId ?? '')) || !Number.isInteger(view.round) || view.round < 0) return null;
39
+ return join(dir, String(view.runId ?? ''), String(view.round), `${view.sha256.toLowerCase()}.${EXTENSIONS[view.contentType]}`);
40
+ }
41
+
42
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
43
+
44
+ async function requireNotALink(path) {
45
+ const found = await lstat(path).catch(() => null);
46
+ if (found?.isSymbolicLink()) {
47
+ throw new Error(`${path} is a link; remove it`);
48
+ }
49
+ return found;
50
+ }
51
+
52
+ /**
53
+ * A local copy of an image, downloaded the first time and never again while the
54
+ * bytes on disk still match their checksum.
55
+ *
56
+ * @param view the attachment as the platform lists it, with `runId` added
57
+ * @param fetchBytes called only when no good copy exists
58
+ */
59
+ export async function localCopy(view, { dir, fetchBytes }) {
60
+ const path = cachedPath(dir, view);
61
+ if (path === null) throw new Error(`${printable(view.name)} is not an image to keep — it opens on cawdev`);
62
+ const wanted = view.sha256.toLowerCase();
63
+ const parts = [String(view.runId ?? ''), String(view.round)];
64
+
65
+ await mkdir(dir, { recursive: true, mode: 0o700 });
66
+ let level = dir;
67
+ await requireNotALink(level);
68
+ for (const part of parts) {
69
+ level = join(level, part);
70
+ await requireNotALink(level);
71
+ await mkdir(level, { mode: 0o700 }).catch((failure) => {
72
+ if (failure.code !== 'EEXIST') throw failure;
73
+ });
74
+ await requireNotALink(level);
75
+ }
76
+
77
+ const there = await lstat(path).catch(() => null);
78
+ if (there) {
79
+ if (!there.isFile()) throw new Error(`${path} is not a regular file; remove it`);
80
+ if (sha256(await readFile(path)) === wanted) return { path, downloaded: false };
81
+ }
82
+
83
+ const bytes = await fetchBytes();
84
+ if (sha256(bytes) !== wanted) {
85
+ throw new Error(`the platform's copy of ${printable(view.name)} does not match its own checksum`);
86
+ }
87
+ // 'wx' is O_EXCL: it fails on anything already at the name — a link planted
88
+ // there included — instead of writing through it. The name is unguessable.
89
+ const partial = `${path}.tmp-${randomUUID()}`;
90
+ try {
91
+ await writeFile(partial, bytes, { mode: 0o600, flag: 'wx' });
92
+ await rename(partial, path);
93
+ } catch (failure) {
94
+ await rm(partial, { force: true });
95
+ throw failure;
96
+ }
97
+ return { path, downloaded: true };
98
+ }
99
+
100
+ /**
101
+ * How to hand files to the OS viewer: a list of [command, args] to spawn, each
102
+ * with an argument array and no shell. macOS gets one `open` with every path, so
103
+ * Preview pages through them in one window.
104
+ */
105
+ export function openerFor(platform) {
106
+ if (platform === 'darwin') return (paths) => (paths.length ? [['open', paths]] : []);
107
+ if (platform === 'win32') return (paths) => paths.map((path) => ['explorer', [path]]);
108
+ return (paths) => paths.map((path) => ['xdg-open', [path]]);
109
+ }
110
+
111
+ /**
112
+ * A name or a caption comes from the session: nothing in it may move the cursor,
113
+ * start a line of its own, or reorder what follows it. `lines` keeps newlines,
114
+ * for a text that is several lines on purpose.
115
+ */
116
+ export function printable(text, { lines = false } = {}) {
117
+ const plain = stripAnsi(String(text ?? ''))
118
+ .replace(/[\u2028\u2029]/g, lines ? '\n' : ' ')
119
+ .replace(lines ? /[\t]/g : /[\t\n\r]/g, ' ')
120
+ .replace(lines ? /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g : /[\x00-\x1f\x7f-\x9f]/g, '');
121
+ // Bidirectional controls: a name that reads one way and means another.
122
+ return plain.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '');
123
+ }
124
+
125
+ /**
126
+ * A person's comments → the decision's `reason` and `notes`.
127
+ *
128
+ * A line starting `#2` is a note on the second screenshot of the round (numbered
129
+ * from 1, in list order); anything else is the round's note. Several lines for
130
+ * one screenshot are joined, because the platform takes one note per shot.
131
+ *
132
+ * @returns {{reason: string, notes: Record<string,string>, error: string|null}}
133
+ */
134
+ export function parseComments(text, shots) {
135
+ const reasons = [];
136
+ const perShot = new Map();
137
+ for (const raw of String(text ?? '').split('\n')) {
138
+ const line = raw.trim();
139
+ if (!line) continue;
140
+ const mine = /^#(\d+)\s*[:—-]?\s*(.*)$/.exec(line);
141
+ if (!mine) {
142
+ reasons.push(line);
143
+ continue;
144
+ }
145
+ const at = Number(mine[1]);
146
+ if (at < 1 || at > shots.length) {
147
+ return { reason: '', notes: {}, error: `there is no screenshot ${at} — this round has ${shots.length}` };
148
+ }
149
+ if (!mine[2]) {
150
+ return { reason: '', notes: {}, error: `write the note after #${at}` };
151
+ }
152
+ perShot.set(at, [...(perShot.get(at) ?? []), mine[2]]);
153
+ }
154
+ const notes = {};
155
+ for (const [at, lines] of perShot) notes[shots[at - 1].id] = lines.join('\n');
156
+ if (reasons.length === 0 && perShot.size === 0) {
157
+ return { reason: '', notes: {}, error: 'write a note on the round or on a screenshot (#2 …)' };
158
+ }
159
+ return { reason: reasons.join('\n'), notes, error: null };
160
+ }
161
+
162
+ /**
163
+ * `{round, ids}` from a `stage:SHOW` approval's `toolInput`, else null. What a
164
+ * gate NAMES is the session's claim until the platform's sealed list agrees.
165
+ */
166
+ export function showGate(approval) {
167
+ if (approval?.toolName !== 'stage:SHOW') return null;
168
+ try {
169
+ const input = typeof approval.toolInput === 'string'
170
+ ? JSON.parse(approval.toolInput || '{}')
171
+ : approval.toolInput ?? {};
172
+ const ids = Array.isArray(input.attachments) ? input.attachments.map(String) : [];
173
+ return { round: Number(input.round ?? 0), ids };
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /** The numbered lines of a round, for the scrollback or a window. */
180
+ export function roundListing(shots, ink = { muted: (text) => text }) {
181
+ return shots.map((shot, at) => {
182
+ const caption = shot.caption ? ` — ${printable(shot.caption)}` : '';
183
+ const html = isImage(shot) ? '' : ink.muted(' (html — opens on cawdev)');
184
+ return ` ${at + 1} ${printable(shot.name)}${caption}${html}`;
185
+ });
186
+ }
187
+
188
+ // --- thumbnails ---------------------------------------------------------------
189
+
190
+ /** Which inline-image escape this terminal speaks, or null: no other gets any. */
191
+ export function thumbnailProtocol(env = process.env) {
192
+ if (env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'WezTerm') return 'iterm';
193
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') return 'kitty';
194
+ return null;
195
+ }
196
+
197
+ /** The bytes of an image as that terminal's inline-image escape, `cols` wide. */
198
+ export function thumbnailEscape(bytes, protocol, { cols = 24 } = {}) {
199
+ const base64 = Buffer.from(bytes).toString('base64');
200
+ if (protocol === 'iterm') {
201
+ return `\x1b]1337;File=inline=1;width=${cols};preserveAspectRatio=1:${base64}\x07`;
202
+ }
203
+ if (protocol === 'kitty') {
204
+ let out = '';
205
+ for (let at = 0; at < base64.length; at += 4096) {
206
+ const chunk = base64.slice(at, at + 4096);
207
+ const more = at + 4096 < base64.length ? 1 : 0;
208
+ const head = at === 0 ? `a=T,f=100,c=${cols},m=${more}` : `m=${more}`;
209
+ out += `\x1b_G${head};${chunk}\x1b\\`;
210
+ }
211
+ return out;
212
+ }
213
+ return '';
214
+ }
@@ -50,7 +50,12 @@ export class Session {
50
50
  }
51
51
  }
52
52
 
53
- async request(path, { method = 'GET', body } = {}) {
53
+ /**
54
+ * The one place a request leaves: the cookie, the XSRF header, the cookies
55
+ * that come back, and the 401 rule. `request` reads JSON from it and
56
+ * `requestBytes` reads bytes; neither may grow a copy of this.
57
+ */
58
+ async #send(path, { method = 'GET', body, redirect } = {}) {
54
59
  const response = await fetch(`${this.url}${path}`, {
55
60
  method,
56
61
  headers: {
@@ -63,9 +68,9 @@ export class Session {
63
68
  : {}),
64
69
  },
65
70
  body: body ? JSON.stringify(body) : undefined,
71
+ ...(redirect ? { redirect } : {}),
66
72
  });
67
73
  this.#remember(response);
68
- const text = await response.text();
69
74
  if (response.status === 401 && this.email !== null) {
70
75
  // The cookie stopped meaning anything: expired, signed out elsewhere, or
71
76
  // the account revoked. Until this every action failed one at a time with
@@ -74,6 +79,12 @@ export class Session {
74
79
  this.email = null;
75
80
  this.onExpired?.();
76
81
  }
82
+ return response;
83
+ }
84
+
85
+ async request(path, { method = 'GET', body } = {}) {
86
+ const response = await this.#send(path, { method, body });
87
+ const text = await response.text();
77
88
  if (!response.ok) {
78
89
  const failure = new Error(messageIn(text) ?? `${method} ${path} failed: HTTP ${response.status}`);
79
90
  failure.status = response.status;
@@ -82,6 +93,22 @@ export class Session {
82
93
  return text ? JSON.parse(text) : null;
83
94
  }
84
95
 
96
+ /**
97
+ * A GET whose answer is bytes — a screenshot (R605). Redirects are refused:
98
+ * these are the platform's own bytes, and a redirect is somebody else's.
99
+ */
100
+ async requestBytes(path) {
101
+ const response = await this.#send(path, { redirect: 'error' });
102
+ if (!response.ok) {
103
+ const failure = new Error(
104
+ messageIn(await response.text()) ?? `GET ${path} failed: HTTP ${response.status}`,
105
+ );
106
+ failure.status = response.status;
107
+ throw failure;
108
+ }
109
+ return Buffer.from(await response.arrayBuffer());
110
+ }
111
+
85
112
  /** One open GET, purely to be handed the CSRF cookie before the first POST. */
86
113
  async prime() {
87
114
  await this.request('/api/health').catch(() => undefined);