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,366 @@
1
+ // The polite network layer every connector goes through. One request at a time
2
+ // per host with a pause between requests and an hourly cap — shared by every
3
+ // strom process on this computer (state in <shared>/net/). An archive that asks
4
+ // us to slow down (429) gets one more try after the wait it asks for, then an
5
+ // hour off; one that refuses (401/403) is left alone for a day; one that does
6
+ // not answer at all is treated the same way: silence from a live server is
7
+ // how a firewall block looks. Repeating a refused request is the quickest way
8
+ // from "slow down" to a blocked IP — for the user, and for every other
9
+ // genealogist behind it.
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { StromError } from "./errors.js";
13
+ import { acquireLock } from "./lock.js";
14
+ import { readJsonIfExists, writeJson } from "./json.js";
15
+ import { VERSION } from "./tree.js";
16
+ import { fetchH2 } from "./http2.js";
17
+ /** The defaults are the fastest strom goes; a connector may only ask for slower. */
18
+ export const DEFAULT_PACE = { minIntervalMs: 2000, perHour: 400 };
19
+ /** How long a host that refused us (401/403) is left alone. */
20
+ export const REFUSED_MS = 24 * 3600_000;
21
+ /** The longest strom waits by itself (an hourly cap, a Retry-After) before it gives up for now. */
22
+ const MAX_WAIT_MS = 2 * 60_000;
23
+ /** An hour off after a host kept asking us to slow down, or stopped answering. */
24
+ export const COOL_OFF_MS = 3600_000;
25
+ const TIMEOUT_MS = 60_000;
26
+ /** Attempts per request: a busy server (5xx) a few, "slow down" (429) and silence only one more. */
27
+ const ATTEMPTS = { busy: 3, slowDown: 2, silent: 2 };
28
+ export const USER_AGENT = `strom-research/${VERSION} (genealogy research; one request at a time, paced)`;
29
+ export class NetError extends StromError {
30
+ failure;
31
+ host;
32
+ status;
33
+ constructor(failure, host, message, hint, status) {
34
+ super(message, hint ? { hint } : {});
35
+ this.name = "NetError";
36
+ this.failure = failure;
37
+ this.host = host;
38
+ this.status = status;
39
+ }
40
+ }
41
+ /** Headers a caller may not set: who we are, and what strom manages. */
42
+ const OWN_HEADERS = new Set(["user-agent", "host", "content-length", "connection", "transfer-encoding"]);
43
+ /** Headers that describe a body: gone when a redirect turns a POST into a GET. */
44
+ const BODY_HEADERS = new Set(["content-type", "content-encoding", "content-language"]);
45
+ /** A minimal cookie jar for one run: name=value per domain and path; enough for a session. */
46
+ export class CookieJar {
47
+ jar = [];
48
+ /** A jar with the cookies kept from before (a file of `entries()`); anything else is left out. */
49
+ static from(entries) {
50
+ const j = new CookieJar();
51
+ if (Array.isArray(entries))
52
+ for (const c of entries)
53
+ if (c && typeof c.name === "string" && typeof c.value === "string" && typeof c.domain === "string" && typeof c.path === "string")
54
+ j.jar.push({ name: c.name, value: c.value, domain: c.domain, hostOnly: c.hostOnly !== false, path: c.path });
55
+ return j;
56
+ }
57
+ /** Its cookies, to keep them between separate requests (the probes of one connector). */
58
+ entries() {
59
+ return this.jar.map((c) => ({ ...c }));
60
+ }
61
+ store(url, setCookies) {
62
+ for (const line of setCookies) {
63
+ const [pair, ...attrs] = line.split(";");
64
+ const eq = pair.indexOf("=");
65
+ if (eq <= 0)
66
+ continue;
67
+ const name = pair.slice(0, eq).trim();
68
+ const value = pair.slice(eq + 1).trim();
69
+ let domain = url.hostname.toLowerCase();
70
+ let hostOnly = true;
71
+ let cpath = "/";
72
+ let expired = false;
73
+ for (const a of attrs) {
74
+ const [k, ...v] = a.split("=");
75
+ const key = k.trim().toLowerCase();
76
+ const val = v.join("=").trim();
77
+ if (key === "domain" && val) {
78
+ const d = val.replace(/^\./, "").toLowerCase();
79
+ if (hostAllowed(url.hostname, [d])) {
80
+ domain = d;
81
+ hostOnly = false;
82
+ }
83
+ }
84
+ else if (key === "path" && val.startsWith("/"))
85
+ cpath = val;
86
+ else if (key === "max-age" && Number(val) <= 0)
87
+ expired = true;
88
+ else if (key === "expires" && Date.parse(val) < Date.now())
89
+ expired = true;
90
+ }
91
+ this.jar = this.jar.filter((c) => !(c.name === name && c.domain === domain && c.path === cpath));
92
+ if (!expired)
93
+ this.jar.push({ name, value, domain, hostOnly, path: cpath });
94
+ }
95
+ }
96
+ header(url) {
97
+ const host = url.hostname.toLowerCase();
98
+ const hits = this.jar.filter((c) => (c.hostOnly ? host === c.domain : hostAllowed(host, [c.domain])) && url.pathname.startsWith(c.path));
99
+ return hits.length ? hits.map((c) => `${c.name}=${c.value}`).join("; ") : undefined;
100
+ }
101
+ }
102
+ /** For tests running strom in-process: record the pauses instead of sleeping (no env or flag reaches this). */
103
+ export const testHooks = {};
104
+ /** The pace for a host: never faster than the defaults. */
105
+ export function paceOf(asked) {
106
+ return {
107
+ minIntervalMs: Math.max(DEFAULT_PACE.minIntervalMs, asked?.minIntervalMs ?? 0),
108
+ perHour: Math.min(DEFAULT_PACE.perHour, asked?.perHour ?? Infinity),
109
+ };
110
+ }
111
+ export function hostAllowed(host, hosts) {
112
+ const h = host.toLowerCase();
113
+ return hosts.some((x) => {
114
+ const a = x.toLowerCase().replace(/^\*\./, "");
115
+ return h === a || h.endsWith("." + a);
116
+ });
117
+ }
118
+ function stateFile(dir, host) {
119
+ return path.join(dir, `${host.replace(/[^a-z0-9.-]/gi, "_")}.json`);
120
+ }
121
+ export function hostState(dir, host) {
122
+ return readJsonIfExists(stateFile(dir, host)) ?? { recent: [] };
123
+ }
124
+ /** The hosts the limiter has met (it keeps a state for each). */
125
+ export function knownHosts(dir) {
126
+ if (!fs.existsSync(dir))
127
+ return [];
128
+ return fs
129
+ .readdirSync(dir)
130
+ .filter((f) => f.endsWith(".json"))
131
+ .map((f) => f.slice(0, -5));
132
+ }
133
+ /** Lift a refusal by hand (the user checked with the archive). */
134
+ export function clearBlock(dir, host) {
135
+ const file = stateFile(dir, host);
136
+ const s = hostState(dir, host);
137
+ delete s.blockedUntil;
138
+ delete s.reason;
139
+ s.slowdown = 1;
140
+ writeJson(file, s);
141
+ }
142
+ function retryAfterMs(value, now) {
143
+ if (!value)
144
+ return undefined;
145
+ const secs = Number(value);
146
+ if (Number.isFinite(secs))
147
+ return secs * 1000;
148
+ const at = Date.parse(value);
149
+ return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
150
+ }
151
+ const when = (ms) => new Date(ms).toISOString().slice(0, 16).replace("T", " ");
152
+ /** One request, politely. Throws NetError when the host is not allowed, refused us, went silent, or is capped. */
153
+ export async function politeRequest(url, opts, redirects = 0) {
154
+ const now = opts.now ?? Date.now;
155
+ const sleep = opts.sleep ?? testHooks.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
156
+ const doFetch = opts.fetchImpl ?? fetch;
157
+ const doFetchH2 = opts.fetchH2 ?? fetchH2;
158
+ let u;
159
+ try {
160
+ u = new URL(url);
161
+ }
162
+ catch {
163
+ throw new NetError("http", "", `not a URL: ${url}`);
164
+ }
165
+ const host = u.hostname;
166
+ if (!/^https?:$/.test(u.protocol))
167
+ throw new NetError("host", host, `only http and https: ${url}`);
168
+ if (!hostAllowed(host, opts.hosts))
169
+ throw new NetError("host", host, `${host} is not one of the hosts this connector may contact (${opts.hosts.join(", ")})`, "a connector names its hosts in connector.json; each needs the user's consent: strom allow host <host>");
170
+ const method = opts.method ?? "GET";
171
+ const headers = {};
172
+ for (const [k, v] of Object.entries(opts.headers ?? {})) {
173
+ if (OWN_HEADERS.has(k.toLowerCase()))
174
+ continue;
175
+ // checked here: what fetch() refuses to send is no silence of the archive
176
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(k))
177
+ throw new NetError("http", host, `request header "${k}": not a header name`);
178
+ if (!/^[\t\x20-\x7e\x80-\xff]*$/.test(String(v)))
179
+ throw new NetError("http", host, `request header ${k}: characters a header cannot carry (a line break, or letters beyond Latin-1)`, "encode it as the portal's pages do: a URL with encodeURI(), other text as the portal expects");
180
+ headers[k.toLowerCase()] = String(v);
181
+ }
182
+ headers["user-agent"] = USER_AGENT;
183
+ const cookie = [headers.cookie, opts.cookies?.header(u)].filter(Boolean).join("; ");
184
+ if (cookie)
185
+ headers.cookie = cookie;
186
+ const pace = paceOf(opts.pace);
187
+ fs.mkdirSync(opts.stateDir, { recursive: true });
188
+ const file = stateFile(opts.stateDir, host);
189
+ const tries = { busy: 0, slowDown: 0, silent: 0 };
190
+ let upgraded = false;
191
+ for (;;) {
192
+ // One request at a time per host, across processes: the lock is held from the pause to the answer.
193
+ const release = acquireLock(`${file}.lock`, { owner: "strom net", waitMs: 10 * 60_000, staleMs: 10 * 60_000 });
194
+ let res;
195
+ let failed;
196
+ let slowed = false;
197
+ let viaH2 = false;
198
+ try {
199
+ let s = hostState(opts.stateDir, host);
200
+ slowed = (s.slowdown ?? 1) > 1;
201
+ const t = now();
202
+ if (s.blockedUntil && s.blockedUntil > t)
203
+ throw new NetError("blocked", host, `${host}: ${s.reason ?? "refused us"} — left alone until ${when(s.blockedUntil)}`, "do not retry: go on with other work; the user can lift it early with strom allow host <host> --unblock");
204
+ s.recent = s.recent.filter((x) => x > t - 3600_000);
205
+ if (s.recent.length >= pace.perHour) {
206
+ const free = s.recent[0] + 3600_000;
207
+ if (free - t > MAX_WAIT_MS)
208
+ throw new NetError("cap", host, `${pace.perHour} requests to ${host} in the last hour — the hourly cap; resumes at ${when(free)}`, "go on with other work and come back later");
209
+ await sleep(free - t);
210
+ }
211
+ const gap = (s.last ?? 0) + pace.minIntervalMs * (s.slowdown ?? 1) - now();
212
+ if (gap > 0)
213
+ await sleep(gap);
214
+ s.last = now();
215
+ s.recent.push(s.last);
216
+ writeJson(file, s);
217
+ viaH2 = !!s.http2;
218
+ try {
219
+ // redirects are followed here, one by one, so that each target is checked and paced
220
+ const body = opts.body !== undefined && method === "POST" ? { body: opts.body } : {};
221
+ res = viaH2
222
+ ? await doFetchH2(url, { method, headers, ...body, signal: AbortSignal.timeout(TIMEOUT_MS) })
223
+ : await doFetch(url, { method, headers, ...body, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) });
224
+ }
225
+ catch (err) {
226
+ failed = err;
227
+ }
228
+ const cool = (ms, reason) => {
229
+ s = hostState(opts.stateDir, host);
230
+ s.blockedUntil = now() + ms;
231
+ s.reason = reason;
232
+ writeJson(file, s);
233
+ };
234
+ if (res && (res.status === 401 || res.status === 403))
235
+ cool(REFUSED_MS, `it refused us (HTTP ${res.status} at ${when(now())})`);
236
+ else if (res?.status === 429 && tries.slowDown + 1 >= ATTEMPTS.slowDown)
237
+ cool(COOL_OFF_MS, `it asked us to slow down twice (HTTP 429 at ${when(now())})`);
238
+ else if (!res && tries.silent + 1 >= ATTEMPTS.silent)
239
+ cool(COOL_OFF_MS, `no answer at ${when(now())} — the server is down, or it blocks this IP`);
240
+ }
241
+ finally {
242
+ release();
243
+ }
244
+ // 426 "Upgrade Required": the server speaks HTTP/2 only (Node's fetch speaks HTTP/1.1).
245
+ // Asked once more over HTTP/2, paced like any request, and remembered for the host.
246
+ if (res?.status === 426 && !viaH2 && !upgraded) {
247
+ upgraded = true;
248
+ const cur = hostState(opts.stateDir, host);
249
+ cur.http2 = true;
250
+ writeJson(file, cur);
251
+ continue;
252
+ }
253
+ if (!res && viaH2) {
254
+ // no HTTP/2 answer: the next request tries HTTP/1.1 again
255
+ const cur = hostState(opts.stateDir, host);
256
+ delete cur.http2;
257
+ writeJson(file, cur);
258
+ }
259
+ if (res && (res.status === 401 || res.status === 403))
260
+ throw new NetError("refused", host, `${host} answered ${res.status} — strom stops and leaves it alone for a day`, "an archive that refuses may block the IP next: stop, check its terms, ask it", res.status);
261
+ if (res)
262
+ opts.cookies?.store(u, res.headers.getSetCookie?.() ?? []);
263
+ const location = res && res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
264
+ if (location) {
265
+ if (redirects >= 5)
266
+ throw new NetError("http", host, `too many redirects from ${url}`);
267
+ // after a POST, a redirect is followed with GET (as browsers do), except 307/308
268
+ const keep = res.status === 307 || res.status === 308 || method !== "POST";
269
+ const next = new URL(location, url);
270
+ const leaves = next.origin !== u.origin;
271
+ if (leaves && keep && opts.private?.body && opts.body !== undefined)
272
+ throw new NetError("http", host, `${url} sends the login on to ${next.origin} — strom does not follow`, "a login goes to the address it was asked for, and nowhere else");
273
+ const own = new Set((leaves ? (opts.private?.headers ?? []) : []).map((k) => k.toLowerCase()));
274
+ const headers = Object.fromEntries(Object.entries(opts.headers ?? {}).filter(([k]) => !own.has(k.toLowerCase()) && (keep || !BODY_HEADERS.has(k.toLowerCase()))));
275
+ return politeRequest(next.toString(), { ...opts, headers, ...(keep ? {} : { method: "GET", body: undefined, private: { headers: opts.private?.headers ?? [], body: false } }) }, redirects + 1);
276
+ }
277
+ const kind = !res ? "silent" : res.status === 429 ? "slowDown" : res.status >= 500 ? "busy" : undefined;
278
+ if (res && !kind) {
279
+ if (slowed) {
280
+ const cur = hostState(opts.stateDir, host);
281
+ cur.slowdown = Math.max(1, (cur.slowdown ?? 1) * 0.8);
282
+ writeJson(file, cur);
283
+ }
284
+ const out = {};
285
+ res.headers.forEach((v, k) => {
286
+ if (k !== "set-cookie")
287
+ out[k] = v;
288
+ });
289
+ return { status: res.status, contentType: res.headers.get("content-type") ?? "", headers: out, body: Buffer.from(await res.arrayBuffer()), url: res.url || url };
290
+ }
291
+ // Busy, "slow down", or no answer: slow down, and try again — a little.
292
+ const cur = hostState(opts.stateDir, host);
293
+ cur.slowdown = Math.min(16, (cur.slowdown ?? 1) * 2);
294
+ writeJson(file, cur);
295
+ tries[kind]++;
296
+ if (tries[kind] >= ATTEMPTS[kind]) {
297
+ if (kind === "silent")
298
+ throw new NetError("silent", host, `no answer from ${host} (${failed?.message ?? "timeout"}) — it is down, or it blocks this IP; strom leaves it alone for an hour`, "do not retry: a firewall that drops requests looks exactly like this");
299
+ if (kind === "slowDown")
300
+ throw new NetError("refused", host, `${host} asked us twice to slow down (429) — strom leaves it alone for an hour`, "go on with other work; a smaller batch next time", 429);
301
+ throw new NetError("http", host, `${host} answered ${res.status} ${tries.busy} times — the server has trouble; try later`, undefined, res.status);
302
+ }
303
+ const asked = res ? retryAfterMs(res.headers.get("retry-after"), now()) : undefined;
304
+ const wait = asked ?? pace.minIntervalMs * 2 ** (tries.busy + tries.slowDown + tries.silent) * 2;
305
+ if (wait > MAX_WAIT_MS)
306
+ throw new NetError("cap", host, `${host} asks us to wait ${Math.round(wait / 60_000)} min — strom does not wait that long`, "go on with other work and come back later", res?.status);
307
+ await sleep(wait);
308
+ }
309
+ }
310
+ /**
311
+ * Times for requests that another program makes — the user's browser — kept in
312
+ * the same shared state as strom's own: the first free slot and then one per
313
+ * pause, within the hourly cap. The browser keeps to them; strom's own requests
314
+ * to the host wait until they are over. Fewer than asked when the hour is full.
315
+ */
316
+ export function reserveSlots(stateDir, host, asked, count, opts = {}) {
317
+ const now = opts.now ?? Date.now;
318
+ const pace = paceOf(asked);
319
+ fs.mkdirSync(stateDir, { recursive: true });
320
+ const file = stateFile(stateDir, host);
321
+ const release = acquireLock(`${file}.lock`, { owner: "strom net", waitMs: 10 * 60_000, staleMs: 10 * 60_000 });
322
+ try {
323
+ const s = hostState(stateDir, host);
324
+ const t = now();
325
+ if (s.blockedUntil && s.blockedUntil > t)
326
+ throw new NetError("blocked", host, `${host}: ${s.reason ?? "refused us"} — left alone until ${when(s.blockedUntil)}`, "do not retry: go on with other work; the user can lift it early with strom allow host <host> --unblock");
327
+ s.recent = s.recent.filter((x) => x > t - 3600_000);
328
+ const free = Math.min(count, pace.perHour - s.recent.length);
329
+ if (free <= 0)
330
+ throw new NetError("cap", host, `${pace.perHour} requests to ${host} in the last hour — the hourly cap; resumes at ${when(s.recent[0] + 3600_000)}`, "go on with other work and come back later");
331
+ const gap = pace.minIntervalMs * (s.slowdown ?? 1);
332
+ const first = Math.max(t + (opts.leadMs ?? 0), (s.last ?? 0) + gap);
333
+ const times = Array.from({ length: free }, (_, i) => first + i * gap);
334
+ s.last = times.at(-1);
335
+ s.recent.push(...times);
336
+ writeJson(file, s);
337
+ return times;
338
+ }
339
+ finally {
340
+ release();
341
+ }
342
+ }
343
+ /** What another program got from a host (the browser): a refusal leaves it alone as if strom had got it. */
344
+ export function refusedBy(stateDir, host, status, now = Date.now) {
345
+ const ms = status === 401 || status === 403 ? REFUSED_MS : status === 429 ? COOL_OFF_MS : undefined;
346
+ if (!ms)
347
+ return undefined;
348
+ fs.mkdirSync(stateDir, { recursive: true });
349
+ const file = stateFile(stateDir, host);
350
+ const release = acquireLock(`${file}.lock`, { owner: "strom net", waitMs: 10 * 60_000, staleMs: 10 * 60_000 });
351
+ try {
352
+ const s = hostState(stateDir, host);
353
+ const reason = status === 429 ? `it asked the browser to slow down (HTTP 429 at ${when(now())})` : `it refused the browser (HTTP ${status} at ${when(now())})`;
354
+ s.blockedUntil = Math.max(s.blockedUntil ?? 0, now() + ms);
355
+ s.reason = reason;
356
+ writeJson(file, s);
357
+ return `${host}: ${reason} — strom leaves it alone until ${when(s.blockedUntil)}`;
358
+ }
359
+ finally {
360
+ release();
361
+ }
362
+ }
363
+ /** A GET — see politeRequest. */
364
+ export function politeGet(url, opts) {
365
+ return politeRequest(url, { ...opts, method: "GET" });
366
+ }
@@ -0,0 +1,29 @@
1
+ // Open a folder, a web address or an installed app the way a double-click
2
+ // would: Finder / Explorer / the file manager, the default browser.
3
+ import { spawn } from "node:child_process";
4
+ /** Open it for the user; false when this computer cannot (no desktop) or tests ask not to. */
5
+ export function openForUser(target, env, platform = process.platform) {
6
+ if (env.STROM_NO_OPEN === "1")
7
+ return false;
8
+ let cmd;
9
+ let args;
10
+ if (platform === "darwin")
11
+ [cmd, args] = ["open", [target]];
12
+ // A link through cmd's start would be cut at its first "&": links go to the handler of their scheme directly.
13
+ else if (platform === "win32")
14
+ [cmd, args] = /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? ["rundll32.exe", ["url.dll,FileProtocolHandler", target]] : [env.ComSpec ?? "cmd.exe", ["/d", "/c", "start", '""', target]];
15
+ else {
16
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
17
+ return false;
18
+ [cmd, args] = ["xdg-open", [target]];
19
+ }
20
+ try {
21
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true, env: env });
22
+ child.on("error", () => undefined);
23
+ child.unref();
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,84 @@
1
+ // Platform-aware default locations. Nothing here touches the disk except
2
+ // existence checks; resolution order lives in config.ts.
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import fs from "node:fs";
6
+ export function userHome(env) {
7
+ return env.HOME || env.USERPROFILE || os.homedir();
8
+ }
9
+ /** Directory holding the small user config (the pointer to the Strom home). */
10
+ export function configDir(env, platform = process.platform) {
11
+ if (env.STROM_CONFIG_DIR)
12
+ return path.resolve(env.STROM_CONFIG_DIR);
13
+ if (platform === "win32") {
14
+ const appData = env.APPDATA || path.join(userHome(env), "AppData", "Roaming");
15
+ return path.join(appData, "strom");
16
+ }
17
+ const xdg = env.XDG_CONFIG_HOME || path.join(userHome(env), ".config");
18
+ return path.join(xdg, "strom");
19
+ }
20
+ /** A folder of the XDG user dirs on Linux (XDG_DOCUMENTS_DIR, XDG_DESKTOP_DIR), if it is set. */
21
+ function xdgUserDir(env, key) {
22
+ const home = userHome(env);
23
+ const fromEnv = env[key];
24
+ if (fromEnv)
25
+ return fromEnv.replace(/^\$HOME/, home);
26
+ const userDirs = path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "user-dirs.dirs");
27
+ try {
28
+ const m = new RegExp(`^${key}="(.+)"$`, "m").exec(fs.readFileSync(userDirs, "utf8"));
29
+ if (m?.[1])
30
+ return m[1].replace(/^\$HOME/, home);
31
+ }
32
+ catch {
33
+ // no user-dirs file
34
+ }
35
+ return undefined;
36
+ }
37
+ /** The user's documents folder (localized names are resolved by the OS). */
38
+ export function documentsDir(env, platform = process.platform) {
39
+ if (env.STROM_DOCUMENTS)
40
+ return path.resolve(expandHome(env.STROM_DOCUMENTS, env));
41
+ const home = userHome(env);
42
+ if (platform === "linux")
43
+ return xdgUserDir(env, "XDG_DOCUMENTS_DIR") ?? path.join(home, "Documents");
44
+ // OneDrive keeps the documents on Windows when it backs them up, as it does the desktop.
45
+ if (platform === "win32") {
46
+ const oneDrive = env.OneDrive ? path.join(env.OneDrive, "Documents") : undefined;
47
+ if (oneDrive && fs.existsSync(oneDrive))
48
+ return oneDrive;
49
+ }
50
+ return path.join(home, "Documents");
51
+ }
52
+ /** The desktop folder (OneDrive keeps it on Windows when it backs the desktop up). */
53
+ export function desktopDir(env, platform = process.platform) {
54
+ const home = userHome(env);
55
+ if (platform === "linux")
56
+ return xdgUserDir(env, "XDG_DESKTOP_DIR") ?? path.join(home, "Desktop");
57
+ if (platform === "win32") {
58
+ const oneDrive = env.OneDrive ? path.join(env.OneDrive, "Desktop") : undefined;
59
+ if (oneDrive && fs.existsSync(oneDrive))
60
+ return oneDrive;
61
+ }
62
+ return path.join(home, "Desktop");
63
+ }
64
+ /** Suggested Strom home: <Documents>/Strom. */
65
+ export function defaultHome(env, platform = process.platform) {
66
+ return path.join(documentsDir(env, platform), "Strom");
67
+ }
68
+ /** Expand a leading "~" so users and agents can pass "~/Documents/Strom". */
69
+ export function expandHome(p, env) {
70
+ if (p === "~")
71
+ return userHome(env);
72
+ if (p.startsWith("~/") || p.startsWith("~\\"))
73
+ return path.join(userHome(env), p.slice(2));
74
+ return p;
75
+ }
76
+ /** Show paths inside the user's home as "~/..." to keep output short. */
77
+ export function displayPath(p, env) {
78
+ const home = userHome(env);
79
+ if (p === home)
80
+ return "~";
81
+ if (p.startsWith(home + path.sep))
82
+ return "~" + path.sep + p.slice(home.length + 1);
83
+ return p;
84
+ }