strom-research 1.22.0 → 1.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.cs.md +24 -0
- package/CHANGELOG.de.md +27 -0
- package/CHANGELOG.md +24 -0
- package/assets/lang/cs.json +14 -0
- package/assets/lang/de.json +14 -0
- package/dist/agents/files.js +12 -2
- package/dist/brief/history.js +6 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu.js +7 -1
- package/dist/cli/seal.js +45 -2
- package/dist/cli/ui.js +14 -0
- package/dist/commands/checks.js +19 -6
- package/dist/commands/history.js +8 -2
- package/dist/commands/setup.js +38 -3
- package/dist/core/changelog.js +1 -1
- package/dist/core/git.js +2 -2
- package/dist/core/integrity.js +104 -14
- package/dist/core/live.js +4 -0
- package/dist/core/oldsettings.js +101 -0
- package/dist/core/packaged.js +69 -0
- package/dist/core/paths.js +9 -5
- package/dist/core/placehistory.js +33 -0
- package/dist/core/seal.js +51 -3
- package/dist/core/tree.js +11 -5
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -29,6 +29,8 @@ const MEDIA_UNNAMED_SAID = 500 * 1024 * 1024;
|
|
|
29
29
|
import { Tree, VERSION } from "../core/tree.js";
|
|
30
30
|
import { timed } from "../core/timing.js";
|
|
31
31
|
import { checked, installUpdate, isNewer, knownNewerVersion, lastChannelOf, latestRelease, newerNode, newerVersion, setInstallChannel, updateChannel, whatsNewUrl } from "../core/update.js";
|
|
32
|
+
import { privateCopies, writesPrivately } from "../core/packaged.js";
|
|
33
|
+
import { moveToTrash } from "../core/trash.js";
|
|
32
34
|
import { configDir, defaultHome, desktopDir, isolated, noLinks, ownCommand } from "../core/paths.js";
|
|
33
35
|
import { backupBefore, backupSaid, lastBackupLine } from "../cli/backups.js";
|
|
34
36
|
import { BACKUP_SAID_DAYS, lastBackup } from "../core/backup.js";
|
|
@@ -41,7 +43,7 @@ import { offerLinks, shortcutName } from "../cli/wizard.js";
|
|
|
41
43
|
import { linkHandlerState, linkScheme, linkStateText } from "../core/links.js";
|
|
42
44
|
import { globalTargets, installGlobal } from "../agents/global.js";
|
|
43
45
|
import { placeholders, ui, UI } from "../cli/ui.js";
|
|
44
|
-
import { foundTrees } from "../cli/seal.js";
|
|
46
|
+
import { adoptSeal, foundTrees } from "../cli/seal.js";
|
|
45
47
|
import { PERMISSION_LEVELS } from "../core/config.js";
|
|
46
48
|
import { PROFILES } from "../agents/profiles.js";
|
|
47
49
|
import { agentSettingsSaid, EFFORTS, hasEffort, ownEffort, raisesEffort, RECOMMENDED_EFFORT } from "../agents/effort.js";
|
|
@@ -58,7 +60,7 @@ import { isArchive } from "../core/mode.js";
|
|
|
58
60
|
import { fixEnv, fixesDoctorLine } from "./fixchecks.js";
|
|
59
61
|
import { placeOf, sampleFiles } from "../core/clouddrive.js";
|
|
60
62
|
import { check } from "../core/check.js";
|
|
61
|
-
import { assertIntact, verifyFull } from "../core/integrity.js";
|
|
63
|
+
import { assertIntact, OTHER_KEY, verifyFull } from "../core/integrity.js";
|
|
62
64
|
import { ensurePluginsDir } from "../core/connector.js";
|
|
63
65
|
import { checkGate, ensureGatesDir } from "../core/gate.js";
|
|
64
66
|
import { ensureHooksDir } from "../core/hooks.js";
|
|
@@ -277,6 +279,10 @@ function diagnoseInto(ctx, checks, t, add, progress) {
|
|
|
277
279
|
add("program", "ok", `${t("ui.doc.innode", { version: VERSION, node: process.version })}${beta}`);
|
|
278
280
|
else
|
|
279
281
|
add("program", "fail", t("ui.doc.oldnode", { node: process.version }), "https://nodejs.org");
|
|
282
|
+
// an update an app packaged for Windows installed for itself alone (core/packaged.ts): that app runs another strom
|
|
283
|
+
const copies = installation().kind === "installed" ? privateCopies(ctx.env, installation().root) : [];
|
|
284
|
+
if (copies.length)
|
|
285
|
+
add("packaged", "warn", t("ui.doc.packaged.copy", { folders: copies.map((c) => ctx.display(c)).join(", ") }), "strom doctor --fix", "packaged");
|
|
280
286
|
// The backup before another channel or an older version: one that could not be made (nothing switches until it is),
|
|
281
287
|
// else where the last one is, for a month
|
|
282
288
|
const backup = lastBackup(ctx.env, BACKUP_SAID_DAYS);
|
|
@@ -518,9 +524,13 @@ function diagnoseInto(ctx, checks, t, add, progress) {
|
|
|
518
524
|
else if (ctx.hasTree()) {
|
|
519
525
|
const tr = ctx.tree();
|
|
520
526
|
progress("tree", tr.config.name);
|
|
521
|
-
const
|
|
527
|
+
const sealErrs = timed("doctor.verify", () => verifyFull(tr).findings.filter((f) => f.level === "error"));
|
|
528
|
+
const errs = timed("doctor.check", () => check(tr).filter((f) => f.level === "error").length) + sealErrs.length;
|
|
522
529
|
if (errs === 0)
|
|
523
530
|
add("tree", "ok", t("ui.doc.tree.ok", { name: tr.config.name }));
|
|
531
|
+
// two seal keys of this computer (strom of an app packaged for Windows and of a terminal): united, never repaired
|
|
532
|
+
else if (sealErrs.length === errs && sealErrs.every((f) => f.code === OTHER_KEY))
|
|
533
|
+
add("tree", "fail", t("ui.doc.tree.twokeys", { name: tr.config.name }), "strom seal adopt", "unite");
|
|
524
534
|
else
|
|
525
535
|
add("tree", "fail", t("ui.doc.tree.bad", { name: tr.config.name, n: errs }), "strom verify ; strom check");
|
|
526
536
|
// can it be written — its locks (.strom) and history (.git) tried: found on Windows, an agent's sandbox refused
|
|
@@ -637,6 +647,20 @@ async function repair(ctx, checks, out) {
|
|
|
637
647
|
}
|
|
638
648
|
if (todo.has("links") && person)
|
|
639
649
|
await offerLinks(ctx, lang);
|
|
650
|
+
// two seal keys of this computer united: as strom seal adopt, the person's yes at a terminal (never an agent)
|
|
651
|
+
if (todo.has("unite") && person && ctx.hasTree()) {
|
|
652
|
+
const r = await adoptSeal(ctx, ctx.tree(), lang);
|
|
653
|
+
if (r.adopted)
|
|
654
|
+
out(ui(lang, "ui.seal.united", { name: ctx.tree().config.name }));
|
|
655
|
+
}
|
|
656
|
+
// the private copies of the program into the trash, on the person's yes in a terminal of their own (from the packaged
|
|
657
|
+
// app itself the copy is what it reads: nothing done there)
|
|
658
|
+
if (todo.has("packaged") && person && !writesPrivately(ctx.env, installation().root) && (await ctx.confirm(ui(lang, "ui.fix.packaged"), true))) {
|
|
659
|
+
for (const c of privateCopies(ctx.env, installation().root)) {
|
|
660
|
+
moveToTrash(c, ctx.env, "Strom (copy of an app)");
|
|
661
|
+
out(ui(lang, "ui.fix.packaged.done", { folder: ctx.display(c) }));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
640
664
|
// a change of strom's that made the reading less sure: returned on the person's yes (a window when an agent asks)
|
|
641
665
|
if (todo.has("tune"))
|
|
642
666
|
await fixTuneWorse(ctx, out);
|
|
@@ -737,6 +761,8 @@ register({
|
|
|
737
761
|
return { text: inst.kind === "npm" ? t("ui.update.npm", { version: newest, tag: channel === "beta" ? "beta" : "latest" }) : t("ui.update.source", { version: newest }), data: { current: VERSION, latest: newest, newer: true, npm: inst.kind === "npm" } };
|
|
738
762
|
// the research where the update replaces the program: nothing asked, backed up or installed
|
|
739
763
|
refuseResearchInProgram(ctx, inst.root);
|
|
764
|
+
// started by a packaged app (Claude's desktop app): the update would be that app's alone (core/packaged.ts)
|
|
765
|
+
refusePackaged(ctx, inst.root);
|
|
740
766
|
const older = newer && isNewer(VERSION, newest);
|
|
741
767
|
const question = switching
|
|
742
768
|
? `${t(`ui.update.channel.${to}.sure`, { version: newest, current: VERSION })} ${older ? t("ui.update.older", { version: newest, current: VERSION }) : t("ui.update.stays")}`
|
|
@@ -807,6 +833,15 @@ function refuseResearchInProgram(ctx, root) {
|
|
|
807
833
|
details: { folder: hit, entry },
|
|
808
834
|
});
|
|
809
835
|
}
|
|
836
|
+
function refusePackaged(ctx, root) {
|
|
837
|
+
if (!writesPrivately(ctx.env, root))
|
|
838
|
+
return;
|
|
839
|
+
throw new StromError(`this strom was started by an app packaged for Windows (MSIX): what it installed into ${ctx.display(root)} only that app would see, the terminal would stay at ${VERSION}; nothing was installed`, {
|
|
840
|
+
hint: "the user runs strom update in their own terminal (PowerShell); the app takes the new version from there",
|
|
841
|
+
code: "update.packaged",
|
|
842
|
+
params: { folder: ctx.display(root), current: VERSION },
|
|
843
|
+
});
|
|
844
|
+
}
|
|
810
845
|
/**
|
|
811
846
|
* Before strom update installs another channel or an older version: the backup the next run of that version would
|
|
812
847
|
* otherwise make (the same change: from what ran here last, its channel, to the version and channel installed). Made
|
package/dist/core/changelog.js
CHANGED
|
@@ -15,7 +15,7 @@ import { eventName, humanDate, humanPlace, humanTask } from "../cli/human.js";
|
|
|
15
15
|
import { UI, ui } from "../cli/ui.js";
|
|
16
16
|
import { actsAsArchive } from "./state.js";
|
|
17
17
|
/** Operations nobody needs to read: what another line says already, or bookkeeping. */
|
|
18
|
-
const QUIET = new Set(["session.metrics", "session.readers", "task.start", "task.continue", "session.note", "tree.settle", "seal.adopt"]);
|
|
18
|
+
const QUIET = new Set(["session.metrics", "session.readers", "task.start", "task.continue", "session.note", "tree.settle", "seal.adopt", "seal.unite"]);
|
|
19
19
|
/** The one-line form of an operation, by its kind: {who} a person or family, {name} the record. */
|
|
20
20
|
/** What strom mode logs (core/mode.ts), as the history says it. */
|
|
21
21
|
const MODE_SAID = { "the research is an archive": "log.mode.archive", "the research works with an agent again": "log.mode.research" };
|
package/dist/core/git.js
CHANGED
|
@@ -276,7 +276,7 @@ function commitTree(dir, tree, parent, message) {
|
|
|
276
276
|
* fewer processes, no hooks, no editor. Returns the hash, or undefined if
|
|
277
277
|
* nothing changed.
|
|
278
278
|
*/
|
|
279
|
-
export function commitAll(dir, message, pathspec = ["."], seal) {
|
|
279
|
+
export function commitAll(dir, message, pathspec = ["."], seal, always = false) {
|
|
280
280
|
// Paths that no longer exist are fine for `add -A` only if git knows them;
|
|
281
281
|
// filter missing, untracked-never-seen paths out to keep `add` from failing.
|
|
282
282
|
const missing = pathspec.filter((p) => p !== "." && !fs.existsSync(path.join(dir, p)));
|
|
@@ -286,7 +286,7 @@ export function commitAll(dir, message, pathspec = ["."], seal) {
|
|
|
286
286
|
git(dir, ["add", "-A", "--", ...specs]);
|
|
287
287
|
const tree = git(dir, ["write-tree"]).trim();
|
|
288
288
|
const parent = headInfo(dir);
|
|
289
|
-
if (parent && parent.tree === tree)
|
|
289
|
+
if (parent && parent.tree === tree && !always)
|
|
290
290
|
return undefined;
|
|
291
291
|
const full = seal ? `${message}\n\n${seal(tree)}\n` : `${message}\n`;
|
|
292
292
|
const r = commitTree(dir, tree, parent?.hash, full);
|
package/dist/core/integrity.js
CHANGED
|
@@ -16,8 +16,9 @@ import fs from "node:fs";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import * as git from "./git.js";
|
|
18
18
|
import { RECORD_TYPES } from "./model.js";
|
|
19
|
-
import { fileSha, fingerprint, verifyCommitSeal, verifyOp } from "./seal.js";
|
|
20
|
-
import {
|
|
19
|
+
import { fileSha, fingerprint, otherKeys, readCommitSeal, verifyCommitSeal, verifyOp } from "./seal.js";
|
|
20
|
+
import { settingsHidden } from "./oldsettings.js";
|
|
21
|
+
import { NeedsConsentError, StromError } from "./errors.js";
|
|
21
22
|
import { counted } from "../cli/format.js";
|
|
22
23
|
/**
|
|
23
24
|
* What strom alone writes in a tree, each change through a sealed operation: the records and their history (data/), the
|
|
@@ -56,8 +57,29 @@ function parseOps(text, file, findings, startLine = 0) {
|
|
|
56
57
|
});
|
|
57
58
|
return out;
|
|
58
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* A research sealed with two keys of this computer: the settings' own and another in an old settings folder (strom of
|
|
62
|
+
* an app packaged for Windows and of a terminal each had their own: core/oldsettings.ts). Its writes are the person's,
|
|
63
|
+
* never "outside strom" — united by the person in their terminal (strom seal adopt), never undone by strom repair.
|
|
64
|
+
*/
|
|
65
|
+
export const OTHER_KEY = "other-key";
|
|
66
|
+
const OTHER_KEY_HINT = "the user runs in their terminal: strom seal adopt (unites the keys — never strom repair, which would undo that work)";
|
|
67
|
+
function keysOf(tree, trust = [], united = new Map()) {
|
|
68
|
+
let others;
|
|
69
|
+
let hidden;
|
|
70
|
+
return {
|
|
71
|
+
hidden: () => (hidden ??= settingsHidden(tree.env)),
|
|
72
|
+
trusted: new Map(trust.map((k) => [fingerprint(k), k])),
|
|
73
|
+
united,
|
|
74
|
+
others: () => (others ??= new Map(tree.key ? otherKeys(tree.env, tree.config.id, tree.key).map((o) => [fingerprint(o.key), o.key]) : [])),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Is an operation shaped as strom signs one (a key's fingerprint and a signature), whatever key it was? */
|
|
78
|
+
function strommade(op) {
|
|
79
|
+
return typeof op.k === "string" && /^[0-9a-f]{12}$/u.test(op.k) && typeof op.sig === "string" && /^[0-9a-f]{32}$/u.test(op.sig);
|
|
80
|
+
}
|
|
59
81
|
/** Check chain and signatures of a run of operations. */
|
|
60
|
-
function checkOps(tree, ops, prevSig, file, findings) {
|
|
82
|
+
function checkOps(tree, ops, prevSig, file, findings, keys = keysOf(tree)) {
|
|
61
83
|
const key = tree.key;
|
|
62
84
|
const fp = key ? fingerprint(key) : undefined;
|
|
63
85
|
let prev = prevSig;
|
|
@@ -70,16 +92,27 @@ function checkOps(tree, ops, prevSig, file, findings) {
|
|
|
70
92
|
adopted = true;
|
|
71
93
|
if (!key)
|
|
72
94
|
continue;
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
const trusted = keys.trusted.get(op.k);
|
|
96
|
+
if (op.k === fp || trusted) {
|
|
97
|
+
if (!verifyOp(op, trusted ?? key))
|
|
75
98
|
findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} has an invalid signature (written outside strom)` });
|
|
76
99
|
}
|
|
100
|
+
else if ((keys.united.get(op.k) ?? "") >= op.at) {
|
|
101
|
+
// another key of this computer, its operations until then checked and taken when the person united the keys
|
|
102
|
+
}
|
|
77
103
|
else if (adopted) {
|
|
78
|
-
|
|
104
|
+
const other = keys.others().get(op.k);
|
|
105
|
+
if (other && verifyOp(op, other))
|
|
106
|
+
findings.push({ level: "error", code: OTHER_KEY, file, message: `operation ${op.op} at ${op.at} is signed with another seal key of strom on this computer (a second copy of strom's settings)`, hint: OTHER_KEY_HINT });
|
|
107
|
+
// a key out of sight only for what strom signs (a fingerprint and a signature): anything else was written outside
|
|
108
|
+
else if (keys.hidden() && strommade(op))
|
|
109
|
+
findings.push({ level: "error", code: OTHER_KEY, file, message: `operation ${op.op} at ${op.at} is signed with a key this strom does not see (an app packaged for Windows started it)`, hint: OTHER_KEY_HINT });
|
|
110
|
+
else
|
|
111
|
+
findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} is signed with an unknown key after the tree was adopted` });
|
|
79
112
|
}
|
|
80
113
|
}
|
|
81
114
|
}
|
|
82
|
-
function headSeal(tree, findings) {
|
|
115
|
+
function headSeal(tree, findings, keys = keysOf(tree)) {
|
|
83
116
|
const head = git.headInfo(tree.root);
|
|
84
117
|
if (!head)
|
|
85
118
|
return;
|
|
@@ -93,7 +126,27 @@ function headSeal(tree, findings) {
|
|
|
93
126
|
});
|
|
94
127
|
return;
|
|
95
128
|
}
|
|
96
|
-
|
|
129
|
+
const sealedWith = (k) => verifyCommitSeal(k, head.tree, head.message);
|
|
130
|
+
if (sealedWith(key) || [...keys.trusted.values()].some(sealedWith))
|
|
131
|
+
return;
|
|
132
|
+
if ([...keys.others().values()].some(sealedWith))
|
|
133
|
+
findings.push({
|
|
134
|
+
level: "error",
|
|
135
|
+
code: OTHER_KEY,
|
|
136
|
+
message: "the last commit was sealed with another seal key of strom on this computer (a second copy of strom's settings)",
|
|
137
|
+
hint: OTHER_KEY_HINT,
|
|
138
|
+
});
|
|
139
|
+
// started by an app packaged for Windows: the terminal's key is out of its sight — the terminal unites them (where
|
|
140
|
+
// seal adopt checks every seal again: a commit of nobody's stays refused). Only a commit with strom's seal: one
|
|
141
|
+
// without (git used directly) is said as it always was, never sent to uniting it cannot do
|
|
142
|
+
else if (keys.hidden() && readCommitSeal(head.message) !== undefined)
|
|
143
|
+
findings.push({
|
|
144
|
+
level: "error",
|
|
145
|
+
code: OTHER_KEY,
|
|
146
|
+
message: "the last commit was not sealed with a key this strom sees — an app packaged for Windows started it, the key of the terminal's strom is out of its sight",
|
|
147
|
+
hint: OTHER_KEY_HINT,
|
|
148
|
+
});
|
|
149
|
+
else
|
|
97
150
|
findings.push({
|
|
98
151
|
level: "error",
|
|
99
152
|
code: "foreign-commit",
|
|
@@ -295,17 +348,44 @@ export function verifyFast(tree, snap = snapshot(tree)) {
|
|
|
295
348
|
findings.unshift(notDownloadedFinding([...cloud].sort()));
|
|
296
349
|
return { findings, tampered: [...tampered] };
|
|
297
350
|
}
|
|
298
|
-
/**
|
|
299
|
-
|
|
351
|
+
/**
|
|
352
|
+
* The other keys of this computer a seal.unite of the tree's own key took, each with when (its operations until then
|
|
353
|
+
* are the person's).
|
|
354
|
+
*/
|
|
355
|
+
function unitedKeys(tree, logs) {
|
|
356
|
+
const united = new Map();
|
|
357
|
+
const key = tree.key;
|
|
358
|
+
if (!key)
|
|
359
|
+
return united;
|
|
360
|
+
const fp = fingerprint(key);
|
|
361
|
+
for (const { ops } of logs)
|
|
362
|
+
for (const op of ops)
|
|
363
|
+
if (op.op === "seal.unite" && op.k === fp && verifyOp(op, key))
|
|
364
|
+
for (const k of op.keys ?? [])
|
|
365
|
+
if ((united.get(k) ?? "") < op.at)
|
|
366
|
+
united.set(k, op.at);
|
|
367
|
+
return united;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Full check: every operation and every record. `trust`: other keys of this computer whose operations and commits are
|
|
371
|
+
* checked with them as the tree's own (strom seal adopt, before it unites them).
|
|
372
|
+
*/
|
|
373
|
+
export function verifyFull(tree, o = {}) {
|
|
300
374
|
const findings = [];
|
|
301
375
|
const tampered = new Set();
|
|
302
|
-
headSeal(tree, findings);
|
|
303
376
|
const latest = new Map();
|
|
304
377
|
const all = [];
|
|
305
|
-
|
|
378
|
+
const logs = opsLogs(tree.dataDir).map((file) => {
|
|
306
379
|
const rel = path.relative(tree.root, file).split(path.sep).join("/");
|
|
307
|
-
|
|
308
|
-
|
|
380
|
+
return { rel, ops: parseOps(fs.readFileSync(file, "utf8"), rel, findings) };
|
|
381
|
+
});
|
|
382
|
+
const keys = keysOf(tree, o.trust, unitedKeys(tree, logs));
|
|
383
|
+
// the last commit said first, as ever
|
|
384
|
+
const headFindings = [];
|
|
385
|
+
headSeal(tree, headFindings, keys);
|
|
386
|
+
findings.unshift(...headFindings);
|
|
387
|
+
for (const { rel, ops } of logs) {
|
|
388
|
+
checkOps(tree, ops, "", rel, findings, keys);
|
|
309
389
|
all.push(...ops);
|
|
310
390
|
}
|
|
311
391
|
// Logs are per session: the last word on a file is the newest operation across all of them.
|
|
@@ -387,6 +467,9 @@ export function assertIntact(tree) {
|
|
|
387
467
|
tree.requireKey();
|
|
388
468
|
return;
|
|
389
469
|
}
|
|
470
|
+
// the last commit sealed with another key of this computer: the person unites them (never strom repair)
|
|
471
|
+
if (errors.every((f) => f.code === OTHER_KEY))
|
|
472
|
+
throw otherKeyError(tree.config.name);
|
|
390
473
|
// files only in iCloud: downloaded first — what else is wrong is judged then
|
|
391
474
|
if (errors.some((f) => f.code === NOT_DOWNLOADED))
|
|
392
475
|
throw notDownloadedError(notDownloaded(tree.root));
|
|
@@ -410,3 +493,10 @@ export function notDownloadedError(files) {
|
|
|
410
493
|
details: files.slice(0, 50),
|
|
411
494
|
});
|
|
412
495
|
}
|
|
496
|
+
/**
|
|
497
|
+
* Writing refused while the last commit carries the seal of another key of this computer: a consent of the person in
|
|
498
|
+
* their terminal (strom seal adopt unites the keys), said in their language (ui.error.seal.other-key).
|
|
499
|
+
*/
|
|
500
|
+
export function otherKeyError(name) {
|
|
501
|
+
return new NeedsConsentError([{ key: "seal", kind: "consent", question: "This tree was sealed with two keys of strom on this computer. Unite them?", set: "strom seal adopt" }], undefined, undefined, { code: "seal.other-key", params: { name, set: "strom seal adopt" } });
|
|
502
|
+
}
|
package/dist/core/live.js
CHANGED
|
@@ -246,6 +246,7 @@ const CODES = {
|
|
|
246
246
|
"ui.media.large": "media.large",
|
|
247
247
|
"ui.media.full": "media.full",
|
|
248
248
|
"ui.seal.bridge": SEAL_FOREIGN,
|
|
249
|
+
"ui.seal.bridge.twokeys": "seal.other-key",
|
|
249
250
|
"ui.error.tree.not-downloaded": "tree.not-downloaded",
|
|
250
251
|
"ui.experiments.link": "experiments.link",
|
|
251
252
|
"ui.history.place.link": "history.link",
|
|
@@ -2240,6 +2241,9 @@ export function serveLive(root, env) {
|
|
|
2240
2241
|
// sealed on another computer since it was asked (its key gone meanwhile): said as the bridge says it before
|
|
2241
2242
|
if (data.code === SEAL_FOREIGN)
|
|
2242
2243
|
reply(409, { ...sealRefusal(), intake: sent.intake });
|
|
2244
|
+
// sealed here with two keys of this computer (an app packaged for Windows and a terminal): united in a terminal
|
|
2245
|
+
else if (data.code === "seal.other-key")
|
|
2246
|
+
reply(409, { ...said(lang, "ui.seal.bridge.twokeys", { name: String(data.params?.name ?? ""), set: "strom seal adopt" }), intake: sent.intake });
|
|
2243
2247
|
// no room on the disk: said as everywhere (507 disk.full), never the system's words with strom's inner path
|
|
2244
2248
|
else if (data.code === DISK_FULL || isDiskFull(why))
|
|
2245
2249
|
reply(507, { ...diskFullSaid(lang, fullWhere(data, root)), reason: why, intake: sent.intake });
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// strom's settings on Windows before 1.23: %APPDATA%\strom. An app packaged as MSIX (Claude's desktop app) gets every
|
|
2
|
+
// file it creates in AppData as a private copy of its own (%LOCALAPPDATA%\Packages\<app>\LocalCache\Roaming\strom),
|
|
3
|
+
// read before the real one and seen by no other program — a strom it started and a strom of a terminal each had their
|
|
4
|
+
// own seal key for one research, each side's writes "outside strom" to the other (found 2026-10-30). Microsoft: "all
|
|
5
|
+
// newly created files and folders in the user's AppData folder are written to a private per-user, per-app location";
|
|
6
|
+
// "apart from AppData, the app can write to any location where the user has write access, including other parts of
|
|
7
|
+
// %userprofile%" (learn.microsoft.com/windows/msix/desktop/flexible-virtualization). The settings are outside AppData
|
|
8
|
+
// now (paths.ts configDir); these folders are read, taken over once, and never moved or deleted: an older strom reads
|
|
9
|
+
// them still (a switch back to an older version).
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { configDir, userHome } from "./paths.js";
|
|
13
|
+
import { writesPrivately } from "./packaged.js";
|
|
14
|
+
import { installation } from "./self.js";
|
|
15
|
+
/**
|
|
16
|
+
* The folders strom kept its settings in before, as this process sees them: %APPDATA%\strom first (in a packaged app
|
|
17
|
+
* its private copy where there is one, else the real one), then each packaged app's copy, read by its path. None with
|
|
18
|
+
* settings of their own (STROM_CONFIG_DIR) and none off Windows. STROM_TEST_OLD_SETTINGS (the sources only): the folders
|
|
19
|
+
* standing for them, separated as PATH is.
|
|
20
|
+
*/
|
|
21
|
+
export function oldSettingsDirs(env, platform = process.platform, inst = installation()) {
|
|
22
|
+
if (inst.kind === "source" && env.STROM_TEST_OLD_SETTINGS !== undefined)
|
|
23
|
+
return env.STROM_TEST_OLD_SETTINGS.split(path.delimiter).filter(Boolean);
|
|
24
|
+
if (env.STROM_CONFIG_DIR || platform !== "win32")
|
|
25
|
+
return [];
|
|
26
|
+
const home = env.USERPROFILE || userHome(env);
|
|
27
|
+
const roaming = path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "strom");
|
|
28
|
+
const packages = path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages");
|
|
29
|
+
let apps = [];
|
|
30
|
+
try {
|
|
31
|
+
apps = fs.readdirSync(packages);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// no packaged apps
|
|
35
|
+
}
|
|
36
|
+
const copies = apps.map((a) => path.join(packages, a, "LocalCache", "Roaming", "strom")).filter((d) => fs.existsSync(d));
|
|
37
|
+
return [roaming, ...copies];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Every packaged app's copy of the old settings as one pattern, those made later too:
|
|
41
|
+
* `%LOCALAPPDATA%\Packages\*\LocalCache\Roaming\strom` — kept from the agents whole (a key an agent put into a copy
|
|
42
|
+
* made after the rules were written would make a commit of nobody's another key of this computer, offered to unite).
|
|
43
|
+
* None where oldSettingsDirs has none. STROM_TEST_PACKAGES (the sources only): the folder standing for Packages.
|
|
44
|
+
*/
|
|
45
|
+
export function packagedSettingsPattern(env, platform = process.platform, inst = installation()) {
|
|
46
|
+
if (inst.kind === "source" && env.STROM_TEST_PACKAGES !== undefined)
|
|
47
|
+
return path.join(env.STROM_TEST_PACKAGES, "*", "LocalCache", "Roaming", "strom");
|
|
48
|
+
if (env.STROM_CONFIG_DIR || platform !== "win32")
|
|
49
|
+
return undefined;
|
|
50
|
+
const home = env.USERPROFILE || userHome(env);
|
|
51
|
+
return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages", "*", "LocalCache", "Roaming", "strom");
|
|
52
|
+
}
|
|
53
|
+
/** What is never copied over: a lock or a file half written of a strom running meanwhile, and the keys (seal.ts). */
|
|
54
|
+
function copied(from, file) {
|
|
55
|
+
if (/\.(lock|tmp)$/u.test(file))
|
|
56
|
+
return false;
|
|
57
|
+
return path.relative(from, file).split(path.sep)[0] !== "keys";
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The settings taken over into the folder of today once, before anything reads them (no config.json there yet): every
|
|
61
|
+
* old folder with settings copied in, the first one's word kept where two say something. The seal keys are taken one by
|
|
62
|
+
* one when a research needs its own (seal.ts settleKey). The folder taken from, or undefined.
|
|
63
|
+
*/
|
|
64
|
+
export function takeOverOldSettings(env, platform = process.platform, inst = installation()) {
|
|
65
|
+
const dir = configDir(env, platform);
|
|
66
|
+
if (fs.existsSync(path.join(dir, "config.json")))
|
|
67
|
+
return undefined;
|
|
68
|
+
const old = oldSettingsDirs(env, platform, inst).filter((d) => fs.existsSync(path.join(d, "config.json")));
|
|
69
|
+
if (!old.length)
|
|
70
|
+
return undefined;
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
+
for (const from of old)
|
|
73
|
+
fs.cpSync(from, dir, { recursive: true, force: false, errorOnExist: false, filter: (file) => copied(from, file) });
|
|
74
|
+
return old[0];
|
|
75
|
+
}
|
|
76
|
+
/** A research's seal key in the old folders, each different one once with where it was (the first seen first). */
|
|
77
|
+
export function oldKeys(env, treeId, inst = installation()) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const dir of oldSettingsDirs(env, process.platform, inst)) {
|
|
80
|
+
const file = path.join(dir, "keys", `${treeId}.key`);
|
|
81
|
+
let key;
|
|
82
|
+
try {
|
|
83
|
+
key = Buffer.from(fs.readFileSync(file, "utf8").trim(), "hex");
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (key.length && !out.some((o) => o.key.equals(key)))
|
|
89
|
+
out.push({ file, key });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether this strom was started by an app packaged for Windows that keeps its own copy of the old settings: the key a
|
|
95
|
+
* strom of the terminal keeps there it cannot see (its own copy reads first) — what is sealed with it is another key of
|
|
96
|
+
* this computer, not git used directly. Tried (core/packaged.ts), only when a seal does not match.
|
|
97
|
+
*/
|
|
98
|
+
export function settingsHidden(env, inst = installation()) {
|
|
99
|
+
const dir = oldSettingsDirs(env, process.platform, inst)[0];
|
|
100
|
+
return !!dir && fs.existsSync(dir) && writesPrivately(env, dir, process.platform, inst);
|
|
101
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A strom started by an app packaged as MSIX (Claude's desktop app on Windows): every file it creates under AppData goes
|
|
2
|
+
// to a private copy of the app's (%LOCALAPPDATA%\Packages\<app>\LocalCache\Local\…), read before the real one and seen
|
|
3
|
+
// by no other program (learn.microsoft.com/windows/msix/desktop/desktop-to-uwp-behind-the-scenes, "AppData operations
|
|
4
|
+
// on Windows 10, version 1903 and later"). strom's program is in %LOCALAPPDATA%\Programs\Strom: an update installed
|
|
5
|
+
// from there would be that app's alone — the app on the new version, the terminal on the old one for good (found
|
|
6
|
+
// 2026-10-30). Whether this process writes so is tried, not guessed: a file made in the folder and looked for in the
|
|
7
|
+
// private copies.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { userHome } from "./paths.js";
|
|
12
|
+
import { installation } from "./self.js";
|
|
13
|
+
/** The AppData folders a packaged app's new files are taken from, each with its folder in the private copy. */
|
|
14
|
+
function appData(env) {
|
|
15
|
+
const home = env.USERPROFILE || userHome(env);
|
|
16
|
+
return [
|
|
17
|
+
{ real: env.LOCALAPPDATA || path.join(home, "AppData", "Local"), copy: "Local" },
|
|
18
|
+
{ real: env.APPDATA || path.join(home, "AppData", "Roaming"), copy: "Roaming" },
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
/** Where the packaged apps keep their private copies of `dir`, whether they are there or not (none: dir outside AppData). */
|
|
22
|
+
function copiesOf(env, dir) {
|
|
23
|
+
const packages = path.join(appData(env)[0].real, "Packages");
|
|
24
|
+
for (const { real, copy } of appData(env)) {
|
|
25
|
+
const rel = path.relative(real, dir);
|
|
26
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
27
|
+
continue;
|
|
28
|
+
let apps = [];
|
|
29
|
+
try {
|
|
30
|
+
apps = fs.readdirSync(packages);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// no packaged apps
|
|
34
|
+
}
|
|
35
|
+
return apps.map((a) => path.join(packages, a, "LocalCache", copy, rel));
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
/** The private copies of `dir` some packaged app made (from the terminal: the update it installed only for itself). */
|
|
40
|
+
export function privateCopies(env, dir, platform = process.platform) {
|
|
41
|
+
if (platform !== "win32")
|
|
42
|
+
return [];
|
|
43
|
+
return copiesOf(env, dir).filter((d) => fs.existsSync(d));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a file this process makes in `dir` lands in a packaged app's private copy instead. STROM_TEST_PACKAGED=1 (the
|
|
47
|
+
* sources only) stands for it. `make` writes the file (a test makes it where Windows would).
|
|
48
|
+
*/
|
|
49
|
+
export function writesPrivately(env, dir, platform = process.platform, inst = installation(), make = (file) => fs.writeFileSync(file, "", { flag: "wx" })) {
|
|
50
|
+
if (inst.kind === "source" && env.STROM_TEST_PACKAGED !== undefined)
|
|
51
|
+
return env.STROM_TEST_PACKAGED === "1";
|
|
52
|
+
if (platform !== "win32")
|
|
53
|
+
return false;
|
|
54
|
+
const copies = copiesOf(env, dir);
|
|
55
|
+
if (!copies.length)
|
|
56
|
+
return false;
|
|
57
|
+
const name = `.strom-probe-${crypto.randomBytes(6).toString("hex")}`;
|
|
58
|
+
try {
|
|
59
|
+
make(path.join(dir, name));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
// the private copy says so; the real folder could have it as well — what counts is the copy
|
|
65
|
+
const hit = copies.some((c) => fs.existsSync(path.join(c, name)));
|
|
66
|
+
for (const at of [dir, ...copies])
|
|
67
|
+
fs.rmSync(path.join(at, name), { force: true });
|
|
68
|
+
return hit;
|
|
69
|
+
}
|
package/dist/core/paths.js
CHANGED
|
@@ -44,14 +44,18 @@ export function noLinks(env) {
|
|
|
44
44
|
export function userHome(env) {
|
|
45
45
|
return env.HOME || env.USERPROFILE || os.homedir();
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Directory holding the small user config (the pointer to the Strom home) and the seal keys. On Windows outside AppData
|
|
49
|
+
* (%USERPROFILE%\.config\strom, as ~/.config/strom elsewhere): an app packaged as MSIX (Claude's desktop app) gets
|
|
50
|
+
* every new file of AppData as a private copy of its own, which no strom of a terminal sees — a research had two seal
|
|
51
|
+
* keys, each side's writes "outside strom" to the other (found 2026-10-30). The folder before: core/oldsettings.ts.
|
|
52
|
+
*/
|
|
48
53
|
export function configDir(env, platform = process.platform) {
|
|
49
54
|
if (env.STROM_CONFIG_DIR)
|
|
50
55
|
return path.resolve(env.STROM_CONFIG_DIR);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return path.join(
|
|
54
|
-
}
|
|
56
|
+
// the profile's own folder (HOME is Git Bash's, which may name another)
|
|
57
|
+
if (platform === "win32")
|
|
58
|
+
return path.join(env.USERPROFILE || userHome(env), ".config", "strom");
|
|
55
59
|
const xdg = env.XDG_CONFIG_HOME || path.join(userHome(env), ".config");
|
|
56
60
|
return path.join(xdg, "strom");
|
|
57
61
|
}
|
|
@@ -218,6 +218,39 @@ export function regionEventsAt(tree, shared, id, at = factsByRecord(tree).get(id
|
|
|
218
218
|
}
|
|
219
219
|
return [...out.values()].sort((a, b) => a.event.from - b.event.from || a.event.id.localeCompare(b.event.id));
|
|
220
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* The own events of the tree's places that belong to a region in the years of an event (their facts' places mapped to
|
|
223
|
+
* it): the mirror of regionEventsAt, for a place looked for before its region (spec 020 K7.1a). Not taken back.
|
|
224
|
+
*/
|
|
225
|
+
export function placeEventsIn(tree, shared, region, e) {
|
|
226
|
+
if (!shared)
|
|
227
|
+
return [];
|
|
228
|
+
const maps = readPlaces(tree);
|
|
229
|
+
const folded = new Map(Object.entries(maps).map(([k, v]) => [foldText(k), v]));
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const [id, at] of factsByRecord(tree)) {
|
|
232
|
+
const inIt = at.keys.some((key) => ((maps[key] ?? folded.get(foldText(key)))?.regions ?? []).some((g) => g.id === region && inRegion(e, g)));
|
|
233
|
+
const p = inIt ? placeRecord(tree, id) : undefined;
|
|
234
|
+
if (!p)
|
|
235
|
+
continue;
|
|
236
|
+
for (const x of readPlaceCatalog(shared, placeKeyOf(tree, p))?.events ?? [])
|
|
237
|
+
if (!x.retracted)
|
|
238
|
+
out.push({ event: x, place: p });
|
|
239
|
+
}
|
|
240
|
+
return out.sort((a, b) => a.event.from - b.event.from || a.event.id.localeCompare(b.event.id));
|
|
241
|
+
}
|
|
242
|
+
/** An event of one of the region's places of the same kind and years: the region's history never writes it again. */
|
|
243
|
+
export function refusePlaceSame(tree, shared, region, e) {
|
|
244
|
+
const same = placeEventsIn(tree, shared, region, e).filter((x) => x.event.kind === e.kind && x.event.from <= e.to && e.from <= x.event.to);
|
|
245
|
+
if (!same.length)
|
|
246
|
+
return;
|
|
247
|
+
const ids = same.map((x) => `${x.event.id} (${x.place.id})`);
|
|
248
|
+
throw new UsageError(`an event of a place of ${region} of the same kind and years is there already: ${ids.join(", ")} — nothing written`, {
|
|
249
|
+
code: "history.same.place",
|
|
250
|
+
params: { ids: same.map((x) => x.event.id).join(", ") },
|
|
251
|
+
hint: `the place's event shows there already — leave it out; an event of the whole region (it touched its other places too): --distinct "<why it is another>"`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
221
254
|
// ── the person's asks ──────────────────────────────────────────────────────
|
|
222
255
|
export function readPlaceAsks(tree) {
|
|
223
256
|
try {
|
package/dist/core/seal.js
CHANGED
|
@@ -16,6 +16,7 @@ import fs from "node:fs";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { configDir } from "./paths.js";
|
|
18
18
|
import { canonicalize } from "./json.js";
|
|
19
|
+
import { oldKeys, oldSettingsDirs } from "./oldsettings.js";
|
|
19
20
|
export function sha256(content) {
|
|
20
21
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
21
22
|
}
|
|
@@ -30,20 +31,67 @@ export function fileSha(file) {
|
|
|
30
31
|
export function keyFile(env, treeId) {
|
|
31
32
|
return path.join(configDir(env), "keys", `${treeId}.key`);
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
function readKey(file) {
|
|
34
35
|
try {
|
|
35
|
-
|
|
36
|
+
const key = Buffer.from(fs.readFileSync(file, "utf8").trim(), "hex");
|
|
37
|
+
return key.length ? key : undefined;
|
|
36
38
|
}
|
|
37
39
|
catch {
|
|
38
40
|
return undefined;
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
|
-
/**
|
|
43
|
+
/** The tree's key on this computer: the settings' own, else one of the old settings folders (core/oldsettings.ts). */
|
|
44
|
+
export function loadKey(env, treeId) {
|
|
45
|
+
return readKey(keyFile(env, treeId)) ?? oldKeys(env, treeId)[0]?.key;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The tree's key, the one in an old settings folder taken over into the settings of today where they have none: of two
|
|
49
|
+
* different ones (strom of a packaged app and of a terminal, each its own) the one `prefer` says (the one that sealed the
|
|
50
|
+
* last commit, the side that could still write), else the first.
|
|
51
|
+
*/
|
|
52
|
+
export function settleKey(env, treeId, prefer) {
|
|
53
|
+
const own = readKey(keyFile(env, treeId));
|
|
54
|
+
if (own)
|
|
55
|
+
return own;
|
|
56
|
+
const old = oldKeys(env, treeId);
|
|
57
|
+
if (!old.length)
|
|
58
|
+
return undefined;
|
|
59
|
+
const key = (old.length > 1 ? old.find((o) => prefer(o.key)) : undefined)?.key ?? old[0].key;
|
|
60
|
+
try {
|
|
61
|
+
const file = keyFile(env, treeId);
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
fs.writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// taken over next time
|
|
67
|
+
}
|
|
68
|
+
return key;
|
|
69
|
+
}
|
|
70
|
+
/** The tree's other keys on this computer: those of the old settings folders that are not `key` (strom seal adopt unites them). */
|
|
71
|
+
export function otherKeys(env, treeId, key) {
|
|
72
|
+
return oldKeys(env, treeId).filter((o) => !o.key.equals(key));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Create the key for a tree on this computer (0600). Fails if it exists. Also into the old settings folder (Windows),
|
|
76
|
+
* made if need be: an older strom, after a switch back, reads it there.
|
|
77
|
+
*/
|
|
42
78
|
export function createKey(env, treeId) {
|
|
43
79
|
const file = keyFile(env, treeId);
|
|
44
80
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
45
81
|
const key = crypto.randomBytes(32);
|
|
46
82
|
fs.writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
|
|
83
|
+
// also where an older strom looks (Windows: %APPDATA%\strom), made if need be — back on an older version, the research
|
|
84
|
+
// is still sealed here
|
|
85
|
+
const old = oldSettingsDirs(env)[0];
|
|
86
|
+
if (old) {
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(path.join(old, "keys"), { recursive: true });
|
|
89
|
+
fs.writeFileSync(path.join(old, "keys", `${treeId}.key`), key.toString("hex") + "\n", { mode: 0o600, flag: "wx" });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// a key of its own there already: the older strom keeps it
|
|
93
|
+
}
|
|
94
|
+
}
|
|
47
95
|
return key;
|
|
48
96
|
}
|
|
49
97
|
/** Short public fingerprint of a key, stored on each operation. */
|