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,300 @@
1
+ // Thin git wrapper. Git is the storage backbone but invisible to the user:
2
+ // `strom` initialises repositories, sets a local identity when none exists
3
+ // and commits on its own.
4
+ import { spawnSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { StromError } from "./errors.js";
8
+ import { userHome } from "./paths.js";
9
+ import { which } from "./which.js";
10
+ const GIT_ENV = {
11
+ GIT_TERMINAL_PROMPT: "0",
12
+ GIT_OPTIONAL_LOCKS: "0",
13
+ LC_ALL: "C",
14
+ };
15
+ const PROFILE = process.env.STROM_PROFILE === "1";
16
+ export function runGit(cwd, args, input) {
17
+ const t0 = PROFILE ? performance.now() : 0;
18
+ const r = spawnSync(gitProgram() ?? "git", args, {
19
+ cwd,
20
+ input,
21
+ encoding: "utf8",
22
+ env: { ...process.env, ...GIT_ENV },
23
+ maxBuffer: 256 * 1024 * 1024,
24
+ windowsHide: true,
25
+ });
26
+ if (r.error) {
27
+ const code = r.error.code;
28
+ if (code === "ENOENT")
29
+ throw gitMissingError();
30
+ throw r.error;
31
+ }
32
+ if (PROFILE)
33
+ process.stderr.write(`[git ${(performance.now() - t0).toFixed(0)}ms] ${args.slice(0, 3).join(" ")}\n`);
34
+ return { status: r.status ?? 1, stdout: r.stdout, stderr: r.stderr };
35
+ }
36
+ function git(cwd, args, input) {
37
+ const r = runGit(cwd, args, input);
38
+ if (r.status !== 0)
39
+ throw new StromError(`git ${args[0]} failed: ${r.stderr.trim() || r.stdout.trim()}`);
40
+ return r.stdout;
41
+ }
42
+ export function gitInstallHint(platform = process.platform) {
43
+ if (platform === "linux")
44
+ return "install git with your package manager, e.g. sudo apt install git / sudo dnf install git";
45
+ return "strom doctor --fix installs it";
46
+ }
47
+ export function gitMissingError() {
48
+ return new StromError("git is not installed — Strom keeps the research history in git", {
49
+ hint: gitInstallHint(),
50
+ });
51
+ }
52
+ /** strom's own git on Windows (MinGit, installed by strom doctor --fix — for strom only, no admin, not on PATH). */
53
+ export function ownGitDir(env) {
54
+ return path.join(env.LOCALAPPDATA ?? path.join(userHome(env), "AppData", "Local"), "Programs", "Strom", "git");
55
+ }
56
+ const programs = new Map();
57
+ /**
58
+ * Where git is: STROM_GIT; strom's own (Windows); on PATH; where Git for
59
+ * Windows installs itself (right after its installer this process's PATH does
60
+ * not have it yet). On macOS /usr/bin/git is only a stand-in until Apple's
61
+ * command line tools are installed — running it would open Apple's installer
62
+ * window unasked — so it counts only with the tools there.
63
+ */
64
+ export function gitProgram(env = process.env, platform = process.platform) {
65
+ const key = [platform, env.STROM_GIT, env.PATH ?? env.Path, env.LOCALAPPDATA].join("|");
66
+ if (programs.has(key))
67
+ return programs.get(key);
68
+ let found;
69
+ if (env.STROM_GIT)
70
+ found = fs.existsSync(env.STROM_GIT) ? env.STROM_GIT : undefined;
71
+ else {
72
+ const own = path.join(ownGitDir(env), "cmd", "git.exe");
73
+ if (platform === "win32" && fs.existsSync(own))
74
+ found = own;
75
+ found ??= which("git", env, platform);
76
+ if (!found && platform === "win32")
77
+ found = [env.ProgramFiles, env.ProgramW6432, env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs")]
78
+ .filter((d) => Boolean(d))
79
+ .map((d) => path.join(d, "Git", "cmd", "git.exe"))
80
+ .find((f) => fs.existsSync(f));
81
+ if (found === "/usr/bin/git" && platform === "darwin" && spawnSync("xcode-select", ["-p"], { stdio: "ignore" }).status !== 0)
82
+ found = undefined;
83
+ }
84
+ programs.set(key, found);
85
+ return found;
86
+ }
87
+ /** Git version string, or undefined when git is not available. */
88
+ export function gitVersion(env = process.env) {
89
+ const git = gitProgram(env);
90
+ if (!git)
91
+ return undefined;
92
+ const r = spawnSync(git, ["--version"], { encoding: "utf8", windowsHide: true });
93
+ if (r.error || r.status !== 0)
94
+ return undefined;
95
+ return r.stdout.trim().replace(/^git version /, "");
96
+ }
97
+ export function isRepo(dir) {
98
+ const r = runGit(dir, ["rev-parse", "--is-inside-work-tree"]);
99
+ return r.status === 0 && r.stdout.trim() === "true";
100
+ }
101
+ export function initRepo(dir) {
102
+ git(dir, ["init", "-q"]);
103
+ // Stable behaviour on every platform and with non-ASCII file names.
104
+ git(dir, ["config", "core.autocrlf", "false"]);
105
+ git(dir, ["config", "core.quotepath", "off"]);
106
+ git(dir, ["config", "commit.gpgsign", "false"]);
107
+ ensureIdentity(dir);
108
+ }
109
+ /** Non-technical users have no git identity; set a local one if needed. */
110
+ export function ensureIdentity(dir) {
111
+ const name = runGit(dir, ["config", "user.name"]).stdout.trim();
112
+ const email = runGit(dir, ["config", "user.email"]).stdout.trim();
113
+ if (!name)
114
+ git(dir, ["config", "user.name", "Strom"]);
115
+ if (!email)
116
+ git(dir, ["config", "user.email", "strom@localhost"]);
117
+ }
118
+ // HEAD only changes when strom commits, so it is read once per command.
119
+ const headCache = new Map();
120
+ /** Forget cached git state (at the start of every command, and when a tree is reopened). */
121
+ export function resetCache(dir) {
122
+ if (dir)
123
+ headCache.delete(dir);
124
+ else {
125
+ headCache.clear();
126
+ programs.clear();
127
+ }
128
+ }
129
+ /** HEAD commit, its tree hash and message (cached until the next commit). */
130
+ export function headInfo(dir) {
131
+ const cached = headCache.get(dir);
132
+ if (cached !== undefined)
133
+ return cached ?? undefined;
134
+ const r = runGit(dir, ["log", "-1", "--format=%H%x00%T%x00%B"]);
135
+ let info = null;
136
+ if (r.status === 0 && r.stdout) {
137
+ const [hash = "", tree = "", ...rest] = r.stdout.split("\0");
138
+ info = { hash: hash.trim(), tree: tree.trim(), message: rest.join("\0") };
139
+ }
140
+ headCache.set(dir, info);
141
+ return info ?? undefined;
142
+ }
143
+ export function hasHead(dir) {
144
+ return headInfo(dir) !== undefined;
145
+ }
146
+ export function head(dir) {
147
+ return headInfo(dir)?.hash;
148
+ }
149
+ function tracked(dir, p) {
150
+ return runGit(dir, ["ls-files", "--error-unmatch", "--", p]).status === 0;
151
+ }
152
+ const FALLBACK_IDENTITY = {
153
+ GIT_AUTHOR_NAME: "Strom",
154
+ GIT_AUTHOR_EMAIL: "strom@localhost",
155
+ GIT_COMMITTER_NAME: "Strom",
156
+ GIT_COMMITTER_EMAIL: "strom@localhost",
157
+ };
158
+ function commitTree(dir, tree, parent, message) {
159
+ const args = ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-F", "-"];
160
+ const r = runGit(dir, args, message);
161
+ if (r.status === 0 || !/identity|tell me who you are|user\.email/i.test(r.stderr))
162
+ return r;
163
+ // No git identity at all (non-technical users): commit as "Strom".
164
+ const retry = spawnSync(gitProgram() ?? "git", args, {
165
+ cwd: dir,
166
+ input: message,
167
+ encoding: "utf8",
168
+ env: { ...process.env, ...GIT_ENV, ...FALLBACK_IDENTITY },
169
+ windowsHide: true,
170
+ });
171
+ return { status: retry.status ?? 1, stdout: retry.stdout, stderr: retry.stderr };
172
+ }
173
+ /**
174
+ * Commit everything under the given paths. `seal` receives the git tree hash
175
+ * of the commit-to-be and returns trailer lines (the commit seal).
176
+ * Plumbing (write-tree, commit-tree, update-ref) instead of `git commit`:
177
+ * fewer processes, no hooks, no editor. Returns the hash, or undefined if
178
+ * nothing changed.
179
+ */
180
+ export function commitAll(dir, message, pathspec = ["."], seal) {
181
+ // Paths that no longer exist are fine for `add -A` only if git knows them;
182
+ // filter missing, untracked-never-seen paths out to keep `add` from failing.
183
+ const specs = pathspec.filter((p) => p === "." || fs.existsSync(path.join(dir, p)) || tracked(dir, p));
184
+ if (specs.length)
185
+ git(dir, ["add", "-A", "--", ...specs]);
186
+ const tree = git(dir, ["write-tree"]).trim();
187
+ const parent = headInfo(dir);
188
+ if (parent && parent.tree === tree)
189
+ return undefined;
190
+ const full = seal ? `${message}\n\n${seal(tree)}\n` : `${message}\n`;
191
+ const r = commitTree(dir, tree, parent?.hash, full);
192
+ if (r.status !== 0)
193
+ throw new StromError(`git commit failed: ${r.stderr.trim()}`);
194
+ const hash = r.stdout.trim();
195
+ git(dir, ["update-ref", "HEAD", hash, ...(parent ? [parent.hash] : [])]);
196
+ headCache.set(dir, { hash, tree, message: full });
197
+ tidy(dir);
198
+ return hash;
199
+ }
200
+ /**
201
+ * Pack loose objects now and then. Plumbing never runs git's own `gc --auto`,
202
+ * so every change of every record would stay a loose file for good (a migrated
203
+ * tree: 30 000 of them, 900 MB). Git's own estimate first — the objects in one
204
+ * of its 256 folders (gc.auto 6700 / 256) — so a commit costs no extra process;
205
+ * `gc --auto` then packs in the background, as `git commit` would.
206
+ */
207
+ function tidy(dir) {
208
+ let n;
209
+ try {
210
+ n = fs.readdirSync(path.join(dir, ".git", "objects", "17")).length;
211
+ }
212
+ catch {
213
+ return;
214
+ }
215
+ if (n > 27)
216
+ runGit(dir, ["gc", "--auto", "--quiet"]);
217
+ }
218
+ /**
219
+ * Read many files at one revision with a single git process.
220
+ * Returns path -> content (undefined when the file did not exist there).
221
+ */
222
+ export function showFiles(dir, rev, files) {
223
+ const out = new Map();
224
+ if (files.length === 0)
225
+ return out;
226
+ const r = spawnSync(gitProgram() ?? "git", ["cat-file", "--batch"], {
227
+ cwd: dir,
228
+ input: files.map((f) => `${rev}:${f.replace(/\\/g, "/")}`).join("\n") + "\n",
229
+ env: { ...process.env, ...GIT_ENV },
230
+ maxBuffer: 1024 * 1024 * 1024,
231
+ windowsHide: true,
232
+ });
233
+ if (r.error)
234
+ throw r.error;
235
+ const buf = r.stdout;
236
+ let pos = 0;
237
+ for (const file of files) {
238
+ const nl = buf.indexOf(10, pos);
239
+ const header = buf.subarray(pos, nl).toString("utf8");
240
+ pos = nl + 1;
241
+ if (header.endsWith(" missing") || header.endsWith(" ambiguous")) {
242
+ out.set(file, undefined);
243
+ continue;
244
+ }
245
+ const size = Number(header.split(" ")[2]);
246
+ out.set(file, buf.subarray(pos, pos + size).toString("utf8"));
247
+ pos += size + 1; // content is followed by a newline
248
+ }
249
+ return out;
250
+ }
251
+ /** Uncommitted changes with their status codes. */
252
+ export function changes(dir, pathspec = []) {
253
+ const out = git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...pathspec]);
254
+ const items = out.split("\0").filter(Boolean);
255
+ const list = [];
256
+ for (let i = 0; i < items.length; i++) {
257
+ const item = items[i];
258
+ const code = item.slice(0, 2);
259
+ list.push({ path: item.slice(3), code });
260
+ if (code.startsWith("R") || code.startsWith("C"))
261
+ i++;
262
+ }
263
+ return list;
264
+ }
265
+ /** File content at a revision, or undefined if it did not exist there. */
266
+ export function showFile(dir, rev, file) {
267
+ const r = runGit(dir, ["show", `${rev}:${file.replace(/\\/g, "/")}`]);
268
+ return r.status === 0 ? r.stdout : undefined;
269
+ }
270
+ /** Files under a directory at a revision. */
271
+ export function listFiles(dir, rev, subdir) {
272
+ const r = runGit(dir, ["ls-tree", "-r", "--name-only", "-z", rev, "--", subdir]);
273
+ if (r.status !== 0)
274
+ return [];
275
+ return r.stdout.split("\0").filter(Boolean);
276
+ }
277
+ /** Newest commit (within `limit`) whose tree hash and message satisfy `ok`. */
278
+ export function findCommit(dir, ok, limit = 1000) {
279
+ const r = runGit(dir, ["log", `-n${limit}`, "--format=%H%x00%T%x00%B%x1e"]);
280
+ if (r.status !== 0)
281
+ return undefined;
282
+ for (const entry of r.stdout.split("\x1e")) {
283
+ const [hash, tree, message] = entry.replace(/^\n/, "").split("\0");
284
+ if (hash && tree && message !== undefined && ok(tree, message))
285
+ return hash;
286
+ }
287
+ return undefined;
288
+ }
289
+ export function log(dir, limit, pathspec = []) {
290
+ if (!hasHead(dir))
291
+ return [];
292
+ const out = git(dir, ["log", `-n${limit}`, "--format=%H%x1f%aI%x1f%s", "--", ...pathspec]);
293
+ return out
294
+ .split("\n")
295
+ .filter(Boolean)
296
+ .map((line) => {
297
+ const [hash = "", at = "", subject = ""] = line.split("\x1f");
298
+ return { hash, at, subject };
299
+ });
300
+ }
@@ -0,0 +1,124 @@
1
+ // `strom guard` — did anything get LOST since the last commit?
2
+ //
3
+ // Nothing in Strom is deleted: records and facts are retracted with a
4
+ // reason. So a record file that disappeared, or an event, note, name,
5
+ // partner or child that vanished from a record, is a loss — unless an
6
+ // operation logged since the last commit names it with a reason.
7
+ // Only files changed since HEAD are examined (one git status + one batched
8
+ // read), so the cost does not grow with the size of the tree.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import * as git from "./git.js";
12
+ import { RECORD_TYPES } from "./model.js";
13
+ import { snapshot } from "./integrity.js";
14
+ /** Array fields whose items must never disappear, with the key identifying an item. */
15
+ const KEPT_ARRAYS = {
16
+ events: (i) => String(i.id),
17
+ notes: (i) => JSON.stringify(i),
18
+ // a person's name is its words (its kind or a citation added is no change of it); a place's name is all of it
19
+ names: (i) => {
20
+ const n = i;
21
+ if (n.given === undefined && n.surname === undefined)
22
+ return JSON.stringify(i);
23
+ return `${n.given ?? ""} /${n.surname ?? ""}/`;
24
+ },
25
+ partners: (i) => String(i),
26
+ children: (i) => String(i.person),
27
+ };
28
+ const RECORD_DIRS = new Set(Object.values(RECORD_TYPES).map((d) => `data/${d.dir}/`));
29
+ function parse(text) {
30
+ if (text === undefined)
31
+ return undefined;
32
+ try {
33
+ return JSON.parse(text);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ export function guard(tree, snap = snapshot(tree)) {
40
+ const out = [];
41
+ if (!git.hasHead(tree.root))
42
+ return out;
43
+ const changes = snap.changes.filter((c) => c.path.startsWith("data/"));
44
+ if (changes.length === 0)
45
+ return out;
46
+ const before = snap.head;
47
+ // Operations logged since HEAD (the new tail of each changed log).
48
+ const justified = new Set();
49
+ for (const c of changes) {
50
+ if (!c.path.startsWith("data/ops/"))
51
+ continue;
52
+ const abs = path.join(tree.root, c.path);
53
+ const old = before.get(c.path) ?? "";
54
+ if (!fs.existsSync(abs)) {
55
+ if (old)
56
+ out.push({ level: "error", code: "ops-deleted", file: c.path, message: "operation log was deleted" });
57
+ continue;
58
+ }
59
+ const now = fs.readFileSync(abs, "utf8");
60
+ if (!now.startsWith(old)) {
61
+ out.push({ level: "error", code: "ops-rewritten", file: c.path, message: "operation log was rewritten (it is append-only)" });
62
+ continue;
63
+ }
64
+ for (const line of now.slice(old.length).split("\n")) {
65
+ if (!line.trim())
66
+ continue;
67
+ try {
68
+ const op = JSON.parse(line);
69
+ if (op.reason)
70
+ for (const t of op.targets)
71
+ justified.add(t);
72
+ }
73
+ catch {
74
+ // reported by verify
75
+ }
76
+ }
77
+ }
78
+ for (const c of changes) {
79
+ const dir = c.path.slice(0, c.path.lastIndexOf("/") + 1);
80
+ if (!RECORD_DIRS.has(dir) || !c.path.endsWith(".json"))
81
+ continue;
82
+ const prev = parse(before.get(c.path));
83
+ if (!prev)
84
+ continue; // new record: nothing to lose
85
+ const id = prev.id ?? path.basename(c.path, ".json");
86
+ const abs = path.join(tree.root, c.path);
87
+ if (!fs.existsSync(abs)) {
88
+ if (!justified.has(id))
89
+ out.push({
90
+ level: "error",
91
+ code: "record-deleted",
92
+ id,
93
+ file: c.path,
94
+ message: "record was deleted",
95
+ hint: "records are never deleted — strom repair, then retract it with --reason",
96
+ });
97
+ continue;
98
+ }
99
+ const cur = parse(fs.readFileSync(abs, "utf8"));
100
+ if (!cur)
101
+ continue;
102
+ for (const [field, keyOf] of Object.entries(KEPT_ARRAYS)) {
103
+ const a = prev[field];
104
+ if (!Array.isArray(a))
105
+ continue;
106
+ const b = cur[field];
107
+ const remaining = new Set(Array.isArray(b) ? b.map(keyOf) : []);
108
+ for (const item of a) {
109
+ const key = keyOf(item);
110
+ if (remaining.has(key) || justified.has(id) || justified.has(key))
111
+ continue;
112
+ out.push({
113
+ level: "error",
114
+ code: "item-removed",
115
+ id,
116
+ file: c.path,
117
+ message: `${field} item removed: ${key.slice(0, 80)}`,
118
+ hint: "retract instead of deleting, or give --reason",
119
+ });
120
+ }
121
+ }
122
+ }
123
+ return out;
124
+ }
@@ -0,0 +1,76 @@
1
+ // One request over HTTP/2, answered as fetch() answers. Node's fetch speaks only
2
+ // HTTP/1.1, and some servers answer that with 426 (Upgrade Required): their
3
+ // image servers, for one. strom then asks them again the way a browser does,
4
+ // over HTTP/2 — with the same user agent, the same pace, the same cookies.
5
+ import http2 from "node:http2";
6
+ import zlib from "node:zlib";
7
+ /** Headers of a connection, not of a request: HTTP/2 has none of them. */
8
+ const CONNECTION_HEADERS = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host"]);
9
+ /** GET, POST or HEAD over HTTP/2 (https: by ALPN, http: by prior knowledge); redirects are not followed. */
10
+ export function fetchH2(url, init) {
11
+ const u = new URL(url);
12
+ return new Promise((resolve, reject) => {
13
+ let settled = false;
14
+ const session = http2.connect(u.origin);
15
+ const done = (err, res) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ session.close();
20
+ if (err)
21
+ reject(err);
22
+ else
23
+ resolve(res);
24
+ };
25
+ session.on("error", (e) => done(e));
26
+ const headers = { ":method": init.method, ":path": u.pathname + u.search };
27
+ for (const [k, v] of Object.entries(init.headers))
28
+ if (!CONNECTION_HEADERS.has(k.toLowerCase()))
29
+ headers[k.toLowerCase()] = v;
30
+ const req = session.request(headers);
31
+ const abort = () => {
32
+ req.close(http2.constants.NGHTTP2_CANCEL);
33
+ done(init.signal?.reason ?? new Error("aborted"));
34
+ };
35
+ if (init.signal?.aborted)
36
+ return abort();
37
+ init.signal?.addEventListener("abort", abort, { once: true });
38
+ let head = {};
39
+ const chunks = [];
40
+ req.on("response", (h) => (head = h));
41
+ req.on("data", (c) => chunks.push(c));
42
+ req.on("error", (e) => done(e));
43
+ req.on("end", () => {
44
+ init.signal?.removeEventListener("abort", abort);
45
+ try {
46
+ const status = Number(head[":status"]);
47
+ const out = new Headers();
48
+ for (const [k, v] of Object.entries(head)) {
49
+ if (k.startsWith(":") || v === undefined)
50
+ continue;
51
+ for (const one of Array.isArray(v) ? v : [String(v)])
52
+ out.append(k, one);
53
+ }
54
+ let body = Buffer.concat(chunks);
55
+ // as fetch does: a compressed answer is read uncompressed
56
+ const enc = String(head["content-encoding"] ?? "").toLowerCase();
57
+ if (body.length && enc === "gzip")
58
+ body = zlib.gunzipSync(body);
59
+ else if (body.length && enc === "br")
60
+ body = zlib.brotliDecompressSync(body);
61
+ else if (body.length && enc === "deflate")
62
+ body = zlib.inflateSync(body);
63
+ if (init.method === "HEAD" || status === 204 || status === 304)
64
+ body = null;
65
+ done(undefined, new Response(body, { status, headers: out }));
66
+ }
67
+ catch (e) {
68
+ done(e);
69
+ }
70
+ });
71
+ if (init.body !== undefined && init.method === "POST")
72
+ req.end(init.body);
73
+ else
74
+ req.end();
75
+ });
76
+ }