@capacms/cli 0.0.0-stage → 0.1.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,450 @@
1
+ /**
2
+ * credentials.mjs — where `capa login` keeps a key, and how every other
3
+ * command finds it again.
4
+ *
5
+ * Two files and one secret store:
6
+ *
7
+ * <config dir>/config.json the profiles: one per key, with the project it
8
+ * reaches, its environment and the API address.
9
+ * No secret is ever in it.
10
+ * the secret store the key itself, under service "capa-cli" and
11
+ * the profile's name:
12
+ * macOS the login keychain (`security`)
13
+ * Linux the Secret Service (`secret-tool`),
14
+ * where a session bus answers
15
+ * else <config dir>/secrets.json, mode 0600
16
+ *
17
+ * The config dir is CAPA_CONFIG_DIR, else $XDG_CONFIG_HOME/capa, else
18
+ * ~/.config/capa. CAPA_CREDENTIAL_STORE=file or =keychain picks the store, and
19
+ * CAPA_KEYCHAIN names a keychain file to use instead of the login keychain
20
+ * (the tests make a throwaway one, so they never touch a person's own). It
21
+ * must exist: `security` given a missing one writes to the login keychain.
22
+ * Every change to config.json or secrets.json holds <config dir>/capa.lock,
23
+ * so two logins at once both keep their profile.
24
+ *
25
+ * THE SECRET NEVER GOES IN ARGV. A process's arguments are readable by every
26
+ * user on the machine (`ps`), so `security add-generic-password -w <key>`
27
+ * would publish the key for as long as it runs. The key is written to
28
+ * `security -i` on stdin instead, and to `secret-tool store` on stdin, which
29
+ * is how that tool reads a secret anyway. `security -i` exits 0 whatever its
30
+ * commands did, so every write is read back before it counts as saved.
31
+ *
32
+ * A profile is a credential RECORD with a `type`. Today every record is
33
+ * `"api_key"`; a sign-in that hands back tokens (the hosted MCP lane's
34
+ * loopback OAuth) is a second type beside it, read by `secretFor` and nothing
35
+ * else, so no command has to change when it lands.
36
+ */
37
+ import crypto from "node:crypto";
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ import { spawnSync } from "node:child_process";
42
+
43
+ export const SERVICE = "capa-cli";
44
+ export const CONFIG_VERSION = 1;
45
+
46
+ /** A process runner: `{ status, stdout, stderr, error }`, as `spawnSync` answers. */
47
+ export function systemRunner(command, args, { input } = {}) {
48
+ const out = spawnSync(command, args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 15_000 });
49
+ return { status: out.status, stdout: out.stdout ?? "", stderr: out.stderr ?? "", error: out.error ?? null };
50
+ }
51
+
52
+ export function configDir(env = process.env) {
53
+ if (env.CAPA_CONFIG_DIR) return path.resolve(env.CAPA_CONFIG_DIR);
54
+ const base = env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), ".config");
55
+ return path.join(base, "capa");
56
+ }
57
+
58
+ /**
59
+ * How old a lock is before it counts as left by a capa that crashed, and how
60
+ * long a second capa waits for one. createCredentials({ lockTimes }) takes
61
+ * other values, `{ staleMs, waitMs }`, so a test of a wait is not 45 s long.
62
+ */
63
+ const LOCK_STALE_MS = 30_000;
64
+ const LOCK_WAIT_MS = 45_000;
65
+ const held = new Set();
66
+ const pause = new Int32Array(new SharedArrayBuffer(4));
67
+
68
+ /** Is process `pid` running? EPERM means it is, as another user. */
69
+ function running(pid) {
70
+ if (!Number.isInteger(pid) || pid <= 0) return false;
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ } catch (error) {
75
+ return error.code === "EPERM";
76
+ }
77
+ }
78
+
79
+ /** The lock file's text, or null when there is none. */
80
+ function lockText(lock) {
81
+ try {
82
+ return fs.readFileSync(lock, "utf8");
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** The lock's text and age, read from one open file so the two belong together; null when there is none. */
89
+ function inspectLock(lock) {
90
+ let fd;
91
+ try {
92
+ fd = fs.openSync(lock, "r");
93
+ } catch {
94
+ return null;
95
+ }
96
+ try {
97
+ const age = Date.now() - fs.fstatSync(fd).mtimeMs;
98
+ return { age, text: fs.readFileSync(fd, "utf8") };
99
+ } finally {
100
+ fs.closeSync(fd);
101
+ }
102
+ }
103
+
104
+ /** Is this lock left by a capa that is gone: older than LOCK_STALE_MS, its pid not running? */
105
+ const abandoned = (seen, staleMs) => seen !== null && seen.age > staleMs && !running(Number.parseInt(seen.text, 10));
106
+
107
+ /**
108
+ * Is this takeover guard left by a waiter that died holding it? A guard is
109
+ * held only while its waiter looks at the lock once, so one older than
110
+ * LOCK_STALE_MS is abandoned whatever its pid says: that pid may be another
111
+ * process's by now, and a guard kept by a reused pid used to block every
112
+ * waiter for good.
113
+ */
114
+ const guardAbandoned = (seen, staleMs) => seen !== null && seen.age > staleMs;
115
+
116
+ /**
117
+ * Removes `lock` if it is abandoned, one waiter at a time. Two waiters can
118
+ * judge the same lock abandoned; the first removes it and makes its own, and
119
+ * the second must not then remove THAT. Checking and then removing leaves a
120
+ * moment between the two (renaming the lock aside and linking a live one
121
+ * back leaves one too: with 8 waiters, 9 of 2,400 profiles were still
122
+ * lost). So a takeover holds `<lock>.takeover`, made with O_EXCL, and looks
123
+ * again while it holds it: while the abandoned lock is there nobody can make
124
+ * a new one, and no other waiter can remove it, so what it sees is what it
125
+ * removes. A waiter that finds the guard taken leaves it to the other, and
126
+ * waits as it would for the lock. A guard left by a crash is cleared once it
127
+ * is abandoned (see guardAbandoned).
128
+ *
129
+ * KNOWN GAP: clearing an abandoned guard is itself check-then-remove. Two
130
+ * waiters that both judge the same dead guard abandoned can each remove one,
131
+ * the second removing the guard the first has just made, and then both hold
132
+ * a guard at once. It needs a waiter to die inside the microseconds it holds
133
+ * the guard, and then costs at most the one race the guard exists to stop;
134
+ * a guard for the guard would only move it. Left as is on purpose.
135
+ */
136
+ function takeOver(lock, staleMs) {
137
+ const guard = `${lock}.takeover`;
138
+ try {
139
+ fs.writeFileSync(guard, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
140
+ } catch (error) {
141
+ if (error.code !== "EEXIST") throw error;
142
+ if (guardAbandoned(inspectLock(guard), staleMs)) fs.rmSync(guard, { force: true });
143
+ return;
144
+ }
145
+ try {
146
+ if (abandoned(inspectLock(lock), staleMs)) fs.rmSync(lock, { force: true });
147
+ } finally {
148
+ fs.rmSync(guard, { force: true });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Runs `fn` holding `<dir>/capa.lock`, so two capa commands saving at once
154
+ * (two logins in two terminals, a script logging in several profiles) each
155
+ * read what the other wrote: config.json and secrets.json are each read,
156
+ * changed and written whole, and without the lock the later write drops the
157
+ * earlier one's profile. The lock is a file made with O_EXCL, holding the
158
+ * holder's pid and a nonce. A holder that crashed leaves one behind, which is
159
+ * taken over once it is LOCK_STALE_MS old AND its pid is not running: a login
160
+ * waiting on a keychain dialog holds it for as long as the dialog is open,
161
+ * and is waited for. A release removes the lock only while it still holds
162
+ * this call's pid and nonce. Re-entrant within a process, since a login
163
+ * writes both files.
164
+ *
165
+ * Every pass that ends with the lock still there pauses and counts against
166
+ * LOCK_WAIT_MS, a takeover that could not act included: a waiter that found
167
+ * the guard taken used to go straight round again, a full core spent for as
168
+ * long as the guard stayed.
169
+ */
170
+ function withLock(dir, fn, { staleMs = LOCK_STALE_MS, waitMs = LOCK_WAIT_MS } = {}) {
171
+ const lock = path.join(dir, "capa.lock");
172
+ if (held.has(lock)) return fn();
173
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
174
+ const mine = `${process.pid} ${crypto.randomBytes(8).toString("hex")}\n`;
175
+ const started = Date.now();
176
+ for (;;) {
177
+ try {
178
+ fs.writeFileSync(lock, mine, { flag: "wx", mode: 0o600 });
179
+ break;
180
+ } catch (error) {
181
+ if (error.code !== "EEXIST") throw error;
182
+ }
183
+ if (abandoned(inspectLock(lock), staleMs)) takeOver(lock, staleMs);
184
+ // Released while we looked, or taken over: try again at once. lstat, not
185
+ // existsSync: a symlink there, dangling or not, fails O_EXCL with EEXIST,
186
+ // and existsSync follows a dangling one, finds nothing and spun the loop.
187
+ if (!fs.lstatSync(lock, { throwIfNoEntry: false })) continue;
188
+ if (Date.now() - started > waitMs) {
189
+ throw new Error(`Another capa command is saving to ${dir} and has not finished. Try again; if no capa is running, delete ${lock} and ${lock}.takeover, whichever is there.`);
190
+ }
191
+ Atomics.wait(pause, 0, 0, 5 + Math.floor(Math.random() * 20));
192
+ }
193
+ held.add(lock);
194
+ try {
195
+ return fn();
196
+ } finally {
197
+ held.delete(lock);
198
+ if (lockText(lock) === mine) fs.rmSync(lock, { force: true });
199
+ }
200
+ }
201
+
202
+ /** Writes a file another user can never read, and never half-written: a temp file in the same directory, renamed over. */
203
+ function writePrivate(file, text) {
204
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
205
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
206
+ fs.writeFileSync(temp, text, { mode: 0o600 });
207
+ fs.renameSync(temp, file);
208
+ fs.chmodSync(file, 0o600);
209
+ }
210
+
211
+ function readJson(file, fallback) {
212
+ let text;
213
+ try {
214
+ text = fs.readFileSync(file, "utf8");
215
+ } catch (error) {
216
+ if (error.code === "ENOENT") return fallback;
217
+ throw error;
218
+ }
219
+ try {
220
+ return JSON.parse(text);
221
+ } catch {
222
+ throw new Error(`${file} is not JSON. Fix it by hand, or delete it and run capa login again.`);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * A key that can be written into a `security -i` command line safely: printable
228
+ * ASCII with no quote, backslash or space. Every key Capa mints is letters,
229
+ * digits and underscores; anything else is refused rather than escaped.
230
+ */
231
+ const SAFE_SECRET = /^[\x21-\x7e]+$/;
232
+ const isSafeSecret = (secret) => SAFE_SECRET.test(secret) && !/["'\\]/.test(secret);
233
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
234
+
235
+ /**
236
+ * Whether `keychain` names a keychain file that is there: the path as given,
237
+ * or a name under ~/Library/Keychains (with or without `-db`), which is where
238
+ * `security` looks for a bare name.
239
+ */
240
+ function keychainExists(keychain, home) {
241
+ const dir = path.join(home, "Library", "Keychains");
242
+ return [keychain, path.join(dir, keychain), path.join(dir, `${keychain}-db`)].some((file) => {
243
+ try {
244
+ return fs.statSync(file).isFile();
245
+ } catch {
246
+ return false;
247
+ }
248
+ });
249
+ }
250
+
251
+ /**
252
+ * The macOS keychain, through `security`. `keychain` is a keychain file, or
253
+ * null for the default search list. A keychain that is not there is refused
254
+ * before `security` runs: given a missing keychain, `add-generic-password`
255
+ * writes to the login keychain instead and `security -i` still exits 0, and
256
+ * the read-back finds it there through the search list.
257
+ */
258
+ export function keychainStore(run, keychain = null, home = os.homedir()) {
259
+ const target = keychain ? [keychain] : [];
260
+ const quoted = keychain ? ` "${keychain}"` : "";
261
+ const present = () => {
262
+ if (keychain && !keychainExists(keychain, home)) {
263
+ throw Object.assign(new Error(`CAPA_KEYCHAIN names ${keychain}, which does not exist. Create it (security create-keychain), fix the path, or unset CAPA_KEYCHAIN to use the login keychain.`), { exitCode: 3 });
264
+ }
265
+ };
266
+ const read = (account) => {
267
+ present();
268
+ const out = run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w", ...target]);
269
+ if (out.error) throw new Error(`could not run security: ${out.error.message}`);
270
+ return out.status === 0 ? out.stdout.replace(/\n$/, "") : null;
271
+ };
272
+ return {
273
+ kind: "keychain",
274
+ label: keychain ? `the keychain ${keychain}` : "the macOS keychain",
275
+ write(account, secret) {
276
+ if (!isSafeSecret(secret)) throw new Error("That key has characters no Capa key has (a space, a quote or a backslash). Check what was pasted.");
277
+ if (keychain && /["\\\n]/.test(keychain)) throw new Error("CAPA_KEYCHAIN has a quote, a backslash or a newline in it.");
278
+ present();
279
+ const line = `add-generic-password -U -s "${SERVICE}" -a "${account}" -l "Capa CLI (${account})" -w "${secret}"${quoted}\n`;
280
+ const out = run("security", ["-i"], { input: line });
281
+ if (out.error) throw new Error(`could not run security: ${out.error.message}`);
282
+ if (read(account) !== secret) throw new Error("The keychain did not keep the key (security answered, but reading it back failed). Use CAPA_CREDENTIAL_STORE=file to keep it in a file instead.");
283
+ },
284
+ read,
285
+ remove(account) {
286
+ present();
287
+ run("security", ["delete-generic-password", "-s", SERVICE, "-a", account, ...target]);
288
+ },
289
+ };
290
+ }
291
+
292
+ /** The Secret Service on Linux, through `secret-tool`. */
293
+ export function secretToolStore(run) {
294
+ return {
295
+ kind: "secret-service",
296
+ label: "the Secret Service keyring",
297
+ write(account, secret) {
298
+ const out = run("secret-tool", ["store", "--label", `Capa CLI (${account})`, "service", SERVICE, "account", account], { input: secret });
299
+ if (out.error || out.status !== 0) throw new Error(`secret-tool could not store the key: ${(out.stderr || out.error?.message || "").trim()}`);
300
+ if (this.read(account) !== secret) throw new Error("The keyring did not keep the key. Use CAPA_CREDENTIAL_STORE=file to keep it in a file instead.");
301
+ },
302
+ read(account) {
303
+ const out = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
304
+ return out.status === 0 && out.stdout ? out.stdout.replace(/\n$/, "") : null;
305
+ },
306
+ remove(account) {
307
+ run("secret-tool", ["clear", "service", SERVICE, "account", account]);
308
+ },
309
+ };
310
+ }
311
+
312
+ /** A file only its owner can read: the store where there is no keychain. */
313
+ export function fileStore(dir) {
314
+ const file = path.join(dir, "secrets.json");
315
+ const load = () => readJson(file, {});
316
+ return {
317
+ kind: "file",
318
+ label: file,
319
+ write(account, secret) {
320
+ withLock(dir, () => writePrivate(file, `${JSON.stringify({ ...load(), [account]: secret }, null, 2)}\n`));
321
+ },
322
+ read(account) {
323
+ const value = load()[account];
324
+ return typeof value === "string" ? value : null;
325
+ },
326
+ remove(account) {
327
+ withLock(dir, () => {
328
+ const all = load();
329
+ if (!(account in all)) return;
330
+ delete all[account];
331
+ if (Object.keys(all).length) writePrivate(file, `${JSON.stringify(all, null, 2)}\n`);
332
+ else fs.rmSync(file, { force: true });
333
+ });
334
+ },
335
+ };
336
+ }
337
+
338
+ /** The store a new key goes to: what CAPA_CREDENTIAL_STORE asks for, else the platform's keychain where it works, else a file. */
339
+ export function chooseStore({ env = process.env, platform = process.platform, run = systemRunner } = {}) {
340
+ const dir = configDir(env);
341
+ const wanted = (env.CAPA_CREDENTIAL_STORE || "").trim();
342
+ if (wanted && wanted !== "file" && wanted !== "keychain") {
343
+ throw new Error(`CAPA_CREDENTIAL_STORE is ${JSON.stringify(wanted)}; it takes file or keychain.`);
344
+ }
345
+ if (wanted === "file") return fileStore(dir);
346
+ if (platform === "darwin") return keychainStore(run, env.CAPA_KEYCHAIN || null, env.HOME || os.homedir());
347
+ if (platform === "linux" && env.DBUS_SESSION_BUS_ADDRESS) {
348
+ const probe = run("secret-tool", ["lookup", "service", SERVICE, "account", "__capa_probe__"]);
349
+ if (!probe.error) return secretToolStore(run);
350
+ }
351
+ if (wanted === "keychain") throw new Error("CAPA_CREDENTIAL_STORE=keychain, but this machine has no keychain the CLI can use (macOS keychain, or secret-tool with a session bus).");
352
+ return fileStore(dir);
353
+ }
354
+
355
+ /** The store a saved profile names, so a key is read back from where it was written. */
356
+ function storeNamed(kind, { env, platform, run }) {
357
+ const dir = configDir(env);
358
+ if (kind === "file") return fileStore(dir);
359
+ if (kind === "keychain") return keychainStore(run, env.CAPA_KEYCHAIN || null, env.HOME || os.homedir());
360
+ if (kind === "secret-service") return secretToolStore(run);
361
+ throw new Error(`a profile names the store ${JSON.stringify(kind)}, which this version of capa does not know. Run capa login again.`);
362
+ }
363
+
364
+ /**
365
+ * The profiles and their keys. Everything here is synchronous and local: no
366
+ * call reaches Capa, so a test can drive it with a fake runner and a temp dir.
367
+ */
368
+ export function createCredentials({ env = process.env, platform = process.platform, run = systemRunner, lockTimes = {} } = {}) {
369
+ const dir = configDir(env);
370
+ const file = path.join(dir, "config.json");
371
+ const load = () => {
372
+ const config = readJson(file, { version: CONFIG_VERSION, current: null, profiles: {} });
373
+ if (!config.profiles || typeof config.profiles !== "object") config.profiles = {};
374
+ return config;
375
+ };
376
+ const save = (config) => writePrivate(file, `${JSON.stringify(config, null, 2)}\n`);
377
+ // The file store's own locking runs inside these, so it re-enters and takes no times of its own.
378
+ const locked = (fn) => withLock(dir, fn, lockTimes);
379
+
380
+ return {
381
+ dir,
382
+ file,
383
+ list() {
384
+ const config = load();
385
+ return { current: config.current ?? null, profiles: Object.entries(config.profiles).map(([name, meta]) => ({ name, ...meta })) };
386
+ },
387
+ /** `{ name, meta }` for `name`, else CAPA_PROFILE, else the current one; null when there is none. */
388
+ active(name = null) {
389
+ const config = load();
390
+ const wanted = name || env.CAPA_PROFILE || config.current;
391
+ if (!wanted) return null;
392
+ const meta = config.profiles[wanted];
393
+ if (!meta) {
394
+ if (name || env.CAPA_PROFILE) {
395
+ const known = Object.keys(config.profiles);
396
+ throw Object.assign(new Error(`No profile ${JSON.stringify(wanted)}.${known.length ? ` Saved: ${known.join(", ")}.` : " Nothing is saved yet: run capa login."}`), { exitCode: 3 });
397
+ }
398
+ return null;
399
+ }
400
+ return { name: wanted, meta };
401
+ },
402
+ secretFor(profile) {
403
+ if (profile.meta.type !== "api_key") {
404
+ throw Object.assign(new Error(`Profile ${profile.name} holds a sign-in of type ${profile.meta.type}, which this version of capa cannot use. Update @capacms/cli, or run capa login with a key.`), { exitCode: 3 });
405
+ }
406
+ return storeNamed(profile.meta.store, { env, platform, run }).read(profile.name);
407
+ },
408
+ /** Saves a key under `name` and makes it current. Answers where the key went. */
409
+ add(name, meta, secret) {
410
+ if (!SAFE_NAME.test(name)) throw Object.assign(new Error(`${JSON.stringify(name)} cannot name a profile: use letters, digits, dots, dashes and underscores.`), { exitCode: 2 });
411
+ const store = chooseStore({ env, platform, run });
412
+ return locked(() => {
413
+ const config = load();
414
+ const before = config.profiles[name];
415
+ store.write(name, secret);
416
+ // A profile moving stores leaves no copy behind in the old one.
417
+ if (before && before.store !== store.kind) storeNamed(before.store, { env, platform, run }).remove(name);
418
+ config.profiles[name] = { type: "api_key", ...meta, store: store.kind, savedAt: new Date().toISOString() };
419
+ config.current = name;
420
+ config.version = CONFIG_VERSION;
421
+ save(config);
422
+ return { store: store.kind, where: store.label };
423
+ });
424
+ },
425
+ use(name) {
426
+ locked(() => {
427
+ const config = load();
428
+ if (!config.profiles[name]) {
429
+ const known = Object.keys(config.profiles);
430
+ throw Object.assign(new Error(`No profile ${JSON.stringify(name)}.${known.length ? ` Saved: ${known.join(", ")}.` : ""}`), { exitCode: 3 });
431
+ }
432
+ config.current = name;
433
+ save(config);
434
+ });
435
+ },
436
+ /** Forgets a profile and deletes its key. Answers the profile, or null when there was none. */
437
+ remove(name) {
438
+ return locked(() => {
439
+ const config = load();
440
+ const meta = config.profiles[name];
441
+ if (!meta) return null;
442
+ storeNamed(meta.store, { env, platform, run }).remove(name);
443
+ delete config.profiles[name];
444
+ if (config.current === name) config.current = Object.keys(config.profiles)[0] ?? null;
445
+ save(config);
446
+ return { name, ...meta };
447
+ });
448
+ },
449
+ };
450
+ }
package/lib/diff.mjs ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * diff.mjs — the change `capa init` is about to make, as a person reads one:
3
+ * removed lines with `-`, added lines with `+`, and two lines of context
4
+ * around each change. Config files are small, so a plain longest-common-
5
+ * subsequence table is fast enough and needs no library.
6
+ */
7
+ export function lineDiff(before, after, { context = 2 } = {}) {
8
+ const a = before === "" ? [] : before.replace(/\n$/, "").split("\n");
9
+ const b = after === "" ? [] : after.replace(/\n$/, "").split("\n");
10
+ const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
11
+ for (let i = a.length - 1; i >= 0; i--) {
12
+ for (let j = b.length - 1; j >= 0; j--) {
13
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
14
+ }
15
+ }
16
+ const ops = [];
17
+ let i = 0;
18
+ let j = 0;
19
+ while (i < a.length || j < b.length) {
20
+ if (i < a.length && j < b.length && a[i] === b[j]) {
21
+ ops.push({ op: " ", line: a[i] });
22
+ i++;
23
+ j++;
24
+ } else if (j < b.length && (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
25
+ ops.push({ op: "+", line: b[j] });
26
+ j++;
27
+ } else {
28
+ ops.push({ op: "-", line: a[i] });
29
+ i++;
30
+ }
31
+ }
32
+ const keep = ops.map((o, k) => o.op !== " " || ops.slice(Math.max(0, k - context), k + context + 1).some((n) => n.op !== " "));
33
+ const out = [];
34
+ let skipped = false;
35
+ ops.forEach((o, k) => {
36
+ if (keep[k]) {
37
+ if (skipped && out.length) out.push(" ...");
38
+ skipped = false;
39
+ out.push(`${o.op} ${o.line}`);
40
+ } else {
41
+ skipped = true;
42
+ }
43
+ });
44
+ return out.join("\n");
45
+ }