strom-research 1.0.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.
Files changed (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,507 @@
1
+ // A tree = one folder = one git repository holding the evidence of one
2
+ // family (it matches one tree in the Strom app). Researches live inside it.
3
+ //
4
+ // Layout:
5
+ // strom.json tree config (name, research language, schema)
6
+ // data/<dir>/<ID>.json one record per file (canonical JSON)
7
+ // data/_counters.json ID counters (never reused)
8
+ // data/ops/*.jsonl append-only operation log
9
+ // output/ generated files (tree.ged, ...)
10
+ // notes/ inputs/ free prose, material from the family
11
+ // .strom/ local state: locks, cache (git-ignored)
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { readJson, readJsonIfExists, writeJson, appendJsonLine, writeFileAtomic } from "./json.js";
16
+ import { acquireLock, withLock } from "./lock.js";
17
+ /** How long a writer waits for the tree while another one works (a long intake, a batch). */
18
+ const LOCK_WAIT_MS = 120_000;
19
+ import { RECORD_TYPES, ALL_PREFIXES, SCHEMA_VERSION } from "./model.js";
20
+ import { validateRecord } from "./validate.js";
21
+ import { NeedsConsentError, StromError, UsageError } from "./errors.js";
22
+ import * as git from "./git.js";
23
+ import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
24
+ import { stringifyCanonical } from "./json.js";
25
+ export const TREE_FILE = "strom.json";
26
+ export const VERSION = "1.0.0";
27
+ const GITIGNORE = `# Strom Research — local state and large files are not versioned
28
+ .strom/
29
+ /media/
30
+ node_modules/
31
+ *.tmp
32
+ .DS_Store
33
+ `;
34
+ export function isTreeDir(dir) {
35
+ return fs.existsSync(path.join(dir, TREE_FILE));
36
+ }
37
+ /** Walk up from `start` to the first folder containing strom.json. */
38
+ export function findTreeUpwards(start) {
39
+ let dir = path.resolve(start);
40
+ for (;;) {
41
+ if (isTreeDir(dir))
42
+ return dir;
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir)
45
+ return undefined;
46
+ dir = parent;
47
+ }
48
+ }
49
+ export function now() {
50
+ return new Date().toISOString();
51
+ }
52
+ export function formatId(prefix, n) {
53
+ return prefix + String(n).padStart(4, "0");
54
+ }
55
+ export function prefixOf(id) {
56
+ return /^([A-Z])\d+$/.exec(id)?.[1];
57
+ }
58
+ export function typeOfId(id) {
59
+ const p = prefixOf(id);
60
+ return p ? typeOfPrefix(p) : undefined;
61
+ }
62
+ export function typeOfPrefix(prefix) {
63
+ for (const [type, def] of Object.entries(RECORD_TYPES))
64
+ if (def.prefix === prefix)
65
+ return type;
66
+ return undefined;
67
+ }
68
+ export class Tree {
69
+ root;
70
+ config;
71
+ /** Who writes: a session ID, "agent" (an agent outside a session) or "user". */
72
+ actor = "user";
73
+ dryRun = false;
74
+ cache = new Map();
75
+ /** Records read one by one before their whole type was loaded. */
76
+ single = new Map();
77
+ /** Last signature per operation log, so appending does not re-read the log. */
78
+ lastSigs = new Map();
79
+ /** Operation logs this process appended to (to commit them). */
80
+ touchedOps = new Set();
81
+ /** Original content of every file this command changed (null = did not exist): the undo log. */
82
+ undo = new Map();
83
+ /** Ops written during this process (for the automatic commit message). */
84
+ written = [];
85
+ env;
86
+ /** Bumped on every write; lets derived indexes know they are stale. */
87
+ version = 0;
88
+ keyCache;
89
+ constructor(root, config, env) {
90
+ this.root = root;
91
+ this.config = config;
92
+ this.env = env;
93
+ }
94
+ static open(root, env) {
95
+ const file = path.join(root, TREE_FILE);
96
+ if (!fs.existsSync(file))
97
+ throw new StromError(`not a Strom tree: ${root}`, { hint: "strom trees" });
98
+ const config = readJson(file);
99
+ if (!config.id)
100
+ throw new StromError(`tree config has no id: ${file}`);
101
+ git.resetCache(root); // other processes (an agent) may have committed since
102
+ if (config.schema > SCHEMA_VERSION)
103
+ throw new StromError(`tree was written by a newer Strom (schema ${config.schema})`, { hint: "update strom" });
104
+ return new Tree(root, config, env);
105
+ }
106
+ /** Create a new tree folder with a git repository and a first commit. */
107
+ static create(root, name, lang, env) {
108
+ if (fs.existsSync(root) && fs.readdirSync(root).length > 0)
109
+ throw new UsageError(`folder is not empty: ${root}`, { hint: "choose a new folder name" });
110
+ if (!git.gitVersion())
111
+ throw git.gitMissingError();
112
+ fs.mkdirSync(root, { recursive: true });
113
+ const config = { schema: SCHEMA_VERSION, id: crypto.randomUUID(), name, lang, created: now(), createdWith: VERSION };
114
+ writeJson(path.join(root, TREE_FILE), config);
115
+ for (const d of ["data", "output", "notes", "inputs"])
116
+ fs.mkdirSync(path.join(root, d), { recursive: true });
117
+ writeJson(path.join(root, "data", "_counters.json"), {});
118
+ writeFileAtomic(path.join(root, ".gitignore"), GITIGNORE);
119
+ for (const d of ["output", "notes", "inputs"])
120
+ writeFileAtomic(path.join(root, d, ".gitkeep"), "");
121
+ git.initRepo(root);
122
+ const tree = new Tree(root, config, env);
123
+ createKey(env, config.id);
124
+ tree.commit(`Create tree "${name}"`);
125
+ return tree;
126
+ }
127
+ get dataDir() {
128
+ return path.join(this.root, "data");
129
+ }
130
+ lockFile() {
131
+ return path.join(this.root, ".strom", "tree.lock");
132
+ }
133
+ lockDepth = 0;
134
+ counters;
135
+ /**
136
+ * Run under the tree lock (re-entrant). ID counters are flushed on the way out.
137
+ * Several strom processes write to one tree side by side (agents in their
138
+ * conversations, a run on its own): whoever takes the lock reads the records
139
+ * afresh, so nobody writes over what another one wrote meanwhile.
140
+ */
141
+ withTreeLock(fn) {
142
+ if (this.lockDepth > 0) {
143
+ this.lockDepth++;
144
+ try {
145
+ return fn();
146
+ }
147
+ finally {
148
+ this.lockDepth--;
149
+ // Back at a whole command's hold (holdTreeLock): its IDs are on disk before it commits.
150
+ if (this.lockDepth === 1)
151
+ this.flushCounters();
152
+ }
153
+ }
154
+ return withLock(this.lockFile(), { owner: `strom ${this.actor}`, waitMs: LOCK_WAIT_MS, staleMs: 60_000 }, () => {
155
+ this.lockDepth = 1;
156
+ this.fresh();
157
+ try {
158
+ return fn();
159
+ }
160
+ finally {
161
+ this.lockDepth = 0;
162
+ this.flushCounters();
163
+ }
164
+ });
165
+ }
166
+ /**
167
+ * Hold the tree lock over a whole command — what it reads, what it writes and
168
+ * its commit — so another process never slips in between (the usual way for
169
+ * a writing command; long ones lock only while they write).
170
+ */
171
+ async holdTreeLock(fn) {
172
+ if (this.lockDepth > 0)
173
+ return fn();
174
+ const release = acquireLock(this.lockFile(), { owner: `strom ${this.actor}`, waitMs: LOCK_WAIT_MS, staleMs: 60_000 });
175
+ this.lockDepth = 1;
176
+ this.fresh();
177
+ try {
178
+ return await fn();
179
+ }
180
+ finally {
181
+ this.lockDepth = 0;
182
+ this.flushCounters();
183
+ release();
184
+ }
185
+ }
186
+ /** Forget what was read before the lock: another process may have written since. */
187
+ fresh() {
188
+ git.resetCache(this.root); // and what git said: another process may have committed while this one waited
189
+ if (this.dryRun)
190
+ return; // a dry run's own writes live only in memory
191
+ this.cache.clear();
192
+ this.single.clear();
193
+ this.lastSigs.clear();
194
+ this.version++;
195
+ }
196
+ flushCounters() {
197
+ // A dry run keeps its counters in memory, so later steps get fresh IDs too.
198
+ if (this.dryRun)
199
+ return;
200
+ if (this.counters) {
201
+ this.remember(this.countersFile());
202
+ writeJson(this.countersFile(), this.counters);
203
+ }
204
+ this.counters = undefined;
205
+ }
206
+ /** Record a file's current content before this command changes it. */
207
+ remember(file) {
208
+ if (this.undo.has(file))
209
+ return;
210
+ try {
211
+ this.undo.set(file, fs.readFileSync(file));
212
+ }
213
+ catch {
214
+ this.undo.set(file, null);
215
+ }
216
+ }
217
+ /** Undo every change this command made — a failed command leaves nothing behind. */
218
+ rollback() {
219
+ const restored = [];
220
+ for (const [file, content] of this.undo) {
221
+ if (content === null)
222
+ fs.rmSync(file, { force: true });
223
+ else
224
+ fs.writeFileSync(file, content);
225
+ restored.push(this.relative(file));
226
+ }
227
+ this.undo.clear();
228
+ this.cache.clear();
229
+ this.single.clear();
230
+ this.lastSigs.clear();
231
+ this.touchedOps.clear();
232
+ this.counters = undefined;
233
+ this.written.length = 0;
234
+ this.version++;
235
+ return restored;
236
+ }
237
+ /** Forget the undo log once the changes are committed. */
238
+ settle() {
239
+ this.undo.clear();
240
+ }
241
+ recordPath(type, id) {
242
+ return path.join(this.dataDir, RECORD_TYPES[type].dir, `${id}.json`);
243
+ }
244
+ relative(p) {
245
+ return path.relative(this.root, p).split(path.sep).join("/");
246
+ }
247
+ // ── reading ────────────────────────────────────────────────────────────
248
+ list(type) {
249
+ let map = this.cache.get(type);
250
+ if (!map) {
251
+ map = new Map();
252
+ const dir = path.join(this.dataDir, RECORD_TYPES[type].dir);
253
+ if (fs.existsSync(dir)) {
254
+ for (const f of fs.readdirSync(dir).sort()) {
255
+ if (!f.endsWith(".json"))
256
+ continue;
257
+ let rec;
258
+ try {
259
+ rec = readJson(path.join(dir, f));
260
+ }
261
+ catch {
262
+ continue; // unreadable record: reported by `strom check`
263
+ }
264
+ map.set(rec.id, rec);
265
+ }
266
+ }
267
+ // Records this process already holds win: after a dry-run write they exist only here.
268
+ for (const [id, rec] of this.single)
269
+ if (rec && rec.type === type)
270
+ map.set(id, rec);
271
+ this.cache.set(type, map);
272
+ }
273
+ return [...map.values()];
274
+ }
275
+ /** One record. Reads just its file unless the whole type is already loaded. */
276
+ get(id) {
277
+ const type = typeOfId(id);
278
+ if (!type)
279
+ return undefined;
280
+ const loaded = this.cache.get(type);
281
+ if (loaded)
282
+ return loaded.get(id);
283
+ let one = this.single.get(id);
284
+ if (one === undefined) {
285
+ try {
286
+ one = readJson(this.recordPath(type, id));
287
+ }
288
+ catch {
289
+ one = null;
290
+ }
291
+ this.single.set(id, one);
292
+ }
293
+ return (one ?? undefined);
294
+ }
295
+ /** Number of records of a type without reading them. */
296
+ count(type) {
297
+ const loaded = this.cache.get(type);
298
+ if (loaded)
299
+ return loaded.size;
300
+ const dir = path.join(this.dataDir, RECORD_TYPES[type].dir);
301
+ if (!fs.existsSync(dir))
302
+ return 0;
303
+ let n = 0;
304
+ for (const f of fs.readdirSync(dir))
305
+ if (f.endsWith(".json"))
306
+ n++;
307
+ return n;
308
+ }
309
+ // ── IDs ────────────────────────────────────────────────────────────────
310
+ countersFile() {
311
+ return path.join(this.dataDir, "_counters.json");
312
+ }
313
+ /** Highest ID number present on disk for a record type (guards counter drift). */
314
+ maxOnDisk(prefix) {
315
+ const type = Object.entries(RECORD_TYPES).find(([, d]) => d.prefix === prefix)?.[0];
316
+ let max = 0;
317
+ const ids = [];
318
+ if (type)
319
+ ids.push(...this.list(type).map((r) => r.id));
320
+ else if (prefix === "E")
321
+ for (const t of ["person", "family"])
322
+ for (const r of this.list(t))
323
+ for (const e of r.events ?? [])
324
+ ids.push(e.id);
325
+ for (const id of ids) {
326
+ const n = Number(id.slice(1));
327
+ if (id.startsWith(prefix) && n > max)
328
+ max = n;
329
+ }
330
+ return max;
331
+ }
332
+ /** Allocate the next ID for a prefix. Call inside withTreeLock. */
333
+ allocate(prefix) {
334
+ if (!Object.values(ALL_PREFIXES).includes(prefix))
335
+ throw new StromError(`unknown ID prefix ${prefix}`);
336
+ if (this.lockDepth === 0)
337
+ throw new StromError("internal: allocate() outside withTreeLock()");
338
+ // Read once per lock, kept in memory, written when the lock is released.
339
+ this.counters ??= readJsonIfExists(this.countersFile()) ?? {};
340
+ // Counters are authoritative; the disk is scanned only when one is missing.
341
+ let next = (this.counters[prefix] ?? this.maxOnDisk(prefix)) + 1;
342
+ // A counter edited by hand must never hand out an ID that is taken.
343
+ const type = typeOfPrefix(prefix);
344
+ if (type)
345
+ while (fs.existsSync(this.recordPath(type, formatId(prefix, next))))
346
+ next++;
347
+ this.counters[prefix] = next;
348
+ return formatId(prefix, next);
349
+ }
350
+ /** Preview the next ID without allocating it. */
351
+ peekId(prefix) {
352
+ const counters = this.counters ?? readJsonIfExists(this.countersFile()) ?? {};
353
+ return formatId(prefix, (counters[prefix] ?? this.maxOnDisk(prefix)) + 1);
354
+ }
355
+ // ── writing ────────────────────────────────────────────────────────────
356
+ opsFile() {
357
+ const month = now().slice(0, 7);
358
+ const name = this.actor === "user" || this.actor === "agent" ? `${this.actor}-${month}` : this.actor;
359
+ return path.join(this.dataDir, "ops", `${name}.jsonl`);
360
+ }
361
+ lastSig(file) {
362
+ const known = this.lastSigs.get(file);
363
+ if (known !== undefined)
364
+ return known;
365
+ if (!fs.existsSync(file))
366
+ return "";
367
+ const text = fs.readFileSync(file, "utf8").trimEnd();
368
+ const last = text.slice(text.lastIndexOf("\n") + 1);
369
+ if (!last)
370
+ return "";
371
+ try {
372
+ return JSON.parse(last).sig ?? "";
373
+ }
374
+ catch {
375
+ return "";
376
+ }
377
+ }
378
+ /** The tree's seal key on this computer, or undefined (tree moved here). */
379
+ get key() {
380
+ if (this.keyCache === undefined)
381
+ this.keyCache = loadKey(this.env, this.config.id) ?? null;
382
+ return this.keyCache ?? undefined;
383
+ }
384
+ lastStamp = 0;
385
+ /** Strictly increasing timestamps: operations are ordered by time across logs. */
386
+ stamp() {
387
+ this.lastStamp = Math.max(Date.now(), this.lastStamp + 1);
388
+ return new Date(this.lastStamp).toISOString();
389
+ }
390
+ /** Operation logs appended to by this process (tree-relative). */
391
+ opsFilesTouched() {
392
+ return [...this.touchedOps].map((f) => this.relative(f));
393
+ }
394
+ /** Forget the cached key (after one was created on this computer). */
395
+ reloadKey() {
396
+ this.keyCache = undefined;
397
+ }
398
+ requireKey() {
399
+ const key = this.key;
400
+ if (key)
401
+ return key;
402
+ throw new NeedsConsentError([
403
+ {
404
+ key: "seal",
405
+ kind: "consent",
406
+ question: "This tree was sealed on another computer. Take it over on this one?",
407
+ set: "strom seal adopt",
408
+ },
409
+ ]);
410
+ }
411
+ /** Sealed commit of everything under pathspec. */
412
+ commit(message, pathspec = ["."]) {
413
+ const key = this.requireKey();
414
+ git.resetCache(this.root); // another process may have committed since this one last looked
415
+ return git.commitAll(this.root, message, pathspec, (treeHash) => commitSeal(key, treeHash));
416
+ }
417
+ /** Append a signed operation to the current log (inside withTreeLock). */
418
+ appendOp(op) {
419
+ const key = this.requireKey();
420
+ const opsFile = this.opsFile();
421
+ const entry = { at: this.stamp(), by: this.actor, ...op, prev: this.lastSig(opsFile), k: fingerprint(key), sig: "" };
422
+ entry.sig = signOp(entry, key);
423
+ this.written.push(entry);
424
+ if (!this.dryRun) {
425
+ this.remember(opsFile);
426
+ appendJsonLine(opsFile, entry);
427
+ this.lastSigs.set(opsFile, entry.sig);
428
+ this.touchedOps.add(opsFile);
429
+ }
430
+ return entry;
431
+ }
432
+ /** Validate and write a record, then log the sealed operation. Call inside withTreeLock. */
433
+ put(input, op) {
434
+ const record = composed(input);
435
+ const problems = validateRecord(record);
436
+ if (problems.length > 0) {
437
+ throw new UsageError(`invalid ${record.type} ${record.id}: ${problems.map((p) => `${p.path} ${p.message}`).join("; ")}`, {
438
+ details: problems,
439
+ });
440
+ }
441
+ const file = this.recordPath(record.type, record.id);
442
+ const content = stringifyCanonical(record);
443
+ this.appendOp({ ...op, summary: op.summary.normalize("NFC"), ...(op.reason ? { reason: op.reason.normalize("NFC") } : {}), files: [{ path: this.relative(file), sha: sha256(content) }] });
444
+ // A dry run keeps the change in memory only, so the next step of the same
445
+ // command (a research on a person it just created) still sees it.
446
+ if (!this.dryRun) {
447
+ this.remember(file);
448
+ writeFileAtomic(file, content);
449
+ }
450
+ this.version++;
451
+ this.single.set(record.id, record);
452
+ this.cache.get(record.type)?.set(record.id, record);
453
+ }
454
+ readOps() {
455
+ const dir = path.join(this.dataDir, "ops");
456
+ if (!fs.existsSync(dir))
457
+ return [];
458
+ const out = [];
459
+ for (const f of fs.readdirSync(dir).sort()) {
460
+ if (!f.endsWith(".jsonl"))
461
+ continue;
462
+ for (const line of fs.readFileSync(path.join(dir, f), "utf8").split("\n")) {
463
+ if (!line.trim())
464
+ continue;
465
+ try {
466
+ out.push(JSON.parse(line));
467
+ }
468
+ catch {
469
+ // a damaged line is reported by `strom verify`
470
+ }
471
+ }
472
+ }
473
+ return out;
474
+ }
475
+ /** Research language for this invocation: an override from outside (--lang, STROM_LANG) or the tree's own. */
476
+ langOverride;
477
+ get lang() {
478
+ return this.langOverride ?? this.config.lang;
479
+ }
480
+ /** Change strom.json as a logged, sealed operation. */
481
+ updateConfig(change, op) {
482
+ this.withTreeLock(() => {
483
+ change(this.config);
484
+ const file = path.join(this.root, TREE_FILE);
485
+ const content = stringifyCanonical(this.config);
486
+ this.appendOp({ op: op.op, targets: [], summary: op.summary, files: [{ path: TREE_FILE, sha: sha256(content) }] });
487
+ if (this.dryRun)
488
+ return;
489
+ this.remember(file);
490
+ writeFileAtomic(file, content);
491
+ });
492
+ }
493
+ }
494
+ /**
495
+ * Every text of a record in composed form (NFC): "č" is one letter whether it
496
+ * came typed, copied or from a macOS file name. File paths stay as they are —
497
+ * on Linux a decomposed name is another file.
498
+ */
499
+ function composed(value, key = "") {
500
+ if (typeof value === "string")
501
+ return key === "file" ? value : value.normalize("NFC");
502
+ if (Array.isArray(value))
503
+ return value.map((v) => composed(v));
504
+ if (value && typeof value === "object")
505
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, composed(v, k)]));
506
+ return value;
507
+ }
@@ -0,0 +1,128 @@
1
+ // What strom put on this computer outside the research, and taking it away
2
+ // again (strom uninstall): what the agents were taught, the shortcut, strom's
3
+ // own git, the PATH lines of the installer and the program itself. The
4
+ // research stays, and so do the settings with the keys that seal it — they
5
+ // are the user's; strom says where they are.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { spawn, spawnSync } from "node:child_process";
9
+ import { desktopDir, userHome } from "./paths.js";
10
+ import { ownGitDir } from "./git.js";
11
+ import { globalTargets, isInstalled, uninstallGlobal } from "../agents/global.js";
12
+ import { PROFILES } from "../agents/profiles.js";
13
+ import { installation } from "./self.js";
14
+ /** The mark the installer (install/install.sh) puts on the PATH line it adds. */
15
+ const MARK = "# strom research";
16
+ function exists(p) {
17
+ try {
18
+ fs.lstatSync(p);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /** A shortcut strom made: its name in any language strom speaks, and it starts strom. */
26
+ function shortcuts(env, platform, names) {
27
+ const desktop = desktopDir(env, platform);
28
+ const files = platform === "darwin"
29
+ ? names.map((n) => path.join(desktop, `${n}.command`))
30
+ : platform === "win32"
31
+ ? names.map((n) => path.join(desktop, `${n}.cmd`))
32
+ : [path.join(env.XDG_DATA_HOME ?? path.join(userHome(env), ".local", "share"), "applications", "strom-research.desktop"), path.join(desktop, "strom-research.desktop")];
33
+ return [...new Set(files)].filter((f) => {
34
+ try {
35
+ return /strom/i.test(fs.readFileSync(f, "utf8"));
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ });
41
+ }
42
+ /** Shell files with the installer's PATH line. */
43
+ function pathLines(env) {
44
+ const home = userHome(env);
45
+ return [".zshrc", ".bashrc", ".bash_profile", ".profile"].map((f) => path.join(home, f)).filter((f) => {
46
+ try {
47
+ return fs.readFileSync(f, "utf8").includes(MARK);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ });
53
+ }
54
+ function dropLines(file) {
55
+ const text = fs.readFileSync(file, "utf8");
56
+ const kept = text.split("\n").filter((l) => !l.includes(MARK));
57
+ // the installer put a blank line before its own
58
+ const out = kept.join("\n").replace(/\n\n+$/, "\n");
59
+ fs.writeFileSync(file, out);
60
+ return true;
61
+ }
62
+ /** The user's PATH on Windows without this folder. */
63
+ function dropWindowsPath(dir) {
64
+ const ps = `$d='${dir.replace(/'/g, "''")}'; $p=[Environment]::GetEnvironmentVariable('Path','User'); if ($p) { [Environment]::SetEnvironmentVariable('Path', (($p -split ';') | Where-Object { $_ -and $_ -ne $d }) -join ';', 'User') }`;
65
+ return spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { stdio: "ignore", windowsHide: true }).status === 0;
66
+ }
67
+ function onWindowsPath(dir) {
68
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "[Environment]::GetEnvironmentVariable('Path','User')"], { encoding: "utf8", windowsHide: true });
69
+ return r.status === 0 && r.stdout.split(";").map((s) => s.trim()).includes(dir);
70
+ }
71
+ /**
72
+ * strom as the installer put it: its folder (Node, strom's code, strom's own
73
+ * git) and the commands on PATH. A running program can be deleted on macOS and
74
+ * Linux; on Windows the folder is deleted by a small command a moment after
75
+ * strom ends.
76
+ */
77
+ function removeInstallation(root, launchers, platform) {
78
+ const outside = launchers.filter((l) => path.dirname(l) !== root);
79
+ for (const l of outside)
80
+ fs.rmSync(l, { force: true });
81
+ if (platform !== "win32") {
82
+ fs.rmSync(root, { recursive: true, force: true });
83
+ return true;
84
+ }
85
+ const child = spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", `ping 127.0.0.1 -n 3 >nul & rmdir /s /q "${root}"`], {
86
+ detached: true,
87
+ stdio: "ignore",
88
+ windowsHide: true,
89
+ });
90
+ child.unref();
91
+ return true;
92
+ }
93
+ /** What strom uninstall takes away here. `names` are the shortcut's names in the languages strom speaks. */
94
+ export function uninstallPlan(env, names, opts = {}) {
95
+ const platform = opts.platform ?? process.platform;
96
+ const remove = [];
97
+ for (const t of globalTargets(env).filter((t) => isInstalled(t)))
98
+ remove.push({ kind: "agent", agent: PROFILES[t.agent]?.name ?? t.agent, path: t.file, remove: () => uninstallGlobal(t) });
99
+ for (const f of shortcuts(env, platform, names))
100
+ remove.push({ kind: "shortcut", path: f, remove: () => (fs.rmSync(f, { force: true }), true) });
101
+ const inst = opts.install ?? installation();
102
+ const git = ownGitDir(env);
103
+ // strom's own git lives in the installation's folder on Windows; alone, it goes by itself.
104
+ if (platform === "win32" && exists(git) && !(inst.root && git.startsWith(inst.root + path.sep)))
105
+ remove.push({ kind: "git", path: git, remove: () => (fs.rmSync(git, { recursive: true, force: true }), true) });
106
+ if (platform !== "win32") {
107
+ for (const f of pathLines(env))
108
+ remove.push({ kind: "path", path: f, remove: () => dropLines(f) });
109
+ const fish = path.join(env.XDG_CONFIG_HOME ?? path.join(userHome(env), ".config"), "fish", "conf.d", "strom.fish");
110
+ try {
111
+ if (fs.readFileSync(fish, "utf8").includes(MARK))
112
+ remove.push({ kind: "path", path: fish, remove: () => (fs.rmSync(fish, { force: true }), true) });
113
+ }
114
+ catch {
115
+ // none
116
+ }
117
+ }
118
+ const program = inst.kind === "installed" ? inst.root : undefined;
119
+ if (program) {
120
+ const launchers = inst.launchers ?? [];
121
+ const dirs = [...new Set([program, ...launchers.map((l) => path.dirname(l))])];
122
+ if (platform === "win32")
123
+ for (const d of dirs.filter((d) => onWindowsPath(d)))
124
+ remove.push({ kind: "path", path: d, remove: () => dropWindowsPath(d) });
125
+ remove.push({ kind: "program", path: program, remove: () => removeInstallation(program, launchers, platform) });
126
+ }
127
+ return { remove, npm: inst.kind === "npm", ...(program ? { program } : {}) };
128
+ }