geneseed 4.9.1 → 4.9.3
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.md +30 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +7 -1
- package/adapters/opencode/plugins/geneseed-guard.js +123 -12
- package/adapters/opencode/plugins/geneseed-learn.js +7 -1
- package/docs/concepts/enforcement-table.md +1 -1
- package/docs/concepts/exclusions.md +1 -2
- package/docs/concepts/hooks.md +3 -2
- package/docs/guides/upgrade.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +30 -9
- package/js/build/args.mjs +9 -8
- package/js/build/driver.mjs +21 -21
- package/js/build/emit-claude.mjs +3 -3
- package/js/build/generate.mjs +4 -5
- package/js/build/source.mjs +18 -22
- package/js/hosts/gitref.mjs +5 -3
- package/js/hosts/hooks-done.mjs +21 -9
- package/js/hosts/hooks-learn.mjs +23 -10
- package/js/hosts/hooks-prims.mjs +25 -0
- package/js/hosts/hooks.mjs +19 -4
- package/js/hosts/installs.mjs +5 -4
- package/js/hosts/loss.mjs +98 -0
- package/js/hosts/native.mjs +3 -1
- package/js/hosts/owned.mjs +34 -6
- package/js/hosts/settings.mjs +6 -5
- package/js/inspect/checks-probe.mjs +1 -1
- package/js/inspect/cost.mjs +37 -5
- package/js/inspect/diff.mjs +154 -64
- package/js/inspect/validate.mjs +2 -2
- package/js/lib/fs.mjs +73 -2
- package/js/maintain/setup.mjs +31 -7
- package/js/maintain/uninstall.mjs +1 -1
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +36 -3
- package/js/web/api.mjs +17 -6
- package/js/web/handler.mjs +4 -16
- package/js/web/jobs.mjs +5 -3
- package/js/web/state.mjs +3 -0
- package/package.json +1 -1
- package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-C8sZVIUU.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-Cc6vWUS4.js} +1 -1
- package/web/dist/assets/Dashboard-C5ATa3ow.js +2 -0
- package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-tDW7i7mo.js} +3 -3
- package/web/dist/assets/GreenhouseView-zk2Uzc3A.js +2 -0
- package/web/dist/assets/Installs-CB_rvbBP.js +3 -0
- package/web/dist/assets/JournalView-Bfj27XHC.js +1 -0
- package/web/dist/assets/Laws-BA3CzUf5.js +3 -0
- package/web/dist/assets/Library-BWwiy9Vb.js +1 -0
- package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-DaPk-ckL.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-CyiGhiHk.js} +1 -1
- package/web/dist/assets/Personal-Bwd27CmT.js +1 -0
- package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-BOD7Kaz1.js} +1 -1
- package/web/dist/assets/drawable-CyLe1gtW.js +1 -0
- package/web/dist/assets/index-DQXG_mqo.js +2 -0
- package/web/dist/index.html +1 -1
- package/web/src/pages/Laws.jsx +60 -353
- package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
- package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
- package/web/dist/assets/Installs-CWuUU4I3.js +0 -2
- package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
- package/web/dist/assets/Laws-R5zZCmya.js +0 -3
- package/web/dist/assets/Library-u36V7oSD.js +0 -1
- package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
- package/web/dist/assets/drawable-CE-arUnF.js +0 -1
- package/web/dist/assets/index-3lnPT_1J.js +0 -2
package/js/hosts/native.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
|
33
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
|
+
import { resolvePath } from './hosts.mjs';
|
|
36
37
|
import { claimLeaf, ownedState } from './owned.mjs';
|
|
37
38
|
// `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
|
|
38
39
|
// (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
|
|
@@ -497,8 +498,9 @@ export function claimer(oldOwned, cfg, manifestExisted = true, oldHashes = {}) {
|
|
|
497
498
|
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
498
499
|
let headerPrinted = false;
|
|
499
500
|
const editRefused = new Set();
|
|
501
|
+
const rroot = cfg != null ? resolvePath(cfg) : undefined; // once per emit, not per leaf
|
|
500
502
|
const claim = (dest) => {
|
|
501
|
-
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest))) return false;
|
|
503
|
+
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest), rroot)) return false;
|
|
502
504
|
if (oldSet === null || cfg == null || !existsSync(dest)) return true;
|
|
503
505
|
const rel = relPosix(cfg, dest);
|
|
504
506
|
if (oldSet.has(rel)) {
|
package/js/hosts/owned.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import path from 'node:path';
|
|
|
26
26
|
|
|
27
27
|
import { resolvePath } from './hosts.mjs';
|
|
28
28
|
import { within } from '../lib/paths.mjs';
|
|
29
|
-
import { isFile } from '../lib/fs.mjs';
|
|
29
|
+
import { isFile, statOrNull, textWrites } from '../lib/fs.mjs';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
@@ -68,9 +68,13 @@ export function ownedWithin(base, rels) {
|
|
|
68
68
|
* `resolvePath` follows the root link on both `root` and `dest`, so a leaf reached the
|
|
69
69
|
* ordinary way through a linked root still resolves inside it and stays allowed — only the
|
|
70
70
|
* LEAF itself being a link is refused.
|
|
71
|
+
*
|
|
72
|
+
* `rroot` is `resolvePath(root)`, passed in by a caller that claims many leaves under one root
|
|
73
|
+
* (`claimer`, once per emit) — re-resolving it per leaf was ~2.6 s of `doctor --all`. Only the
|
|
74
|
+
* ROOT is hoisted: the lstat and the leaf's own `resolvePath` below stay per call, since they
|
|
75
|
+
* are the boundary.
|
|
71
76
|
*/
|
|
72
|
-
export function safeLeaf(root, dest) {
|
|
73
|
-
const rroot = resolvePath(root);
|
|
77
|
+
export function safeLeaf(root, dest, rroot = resolvePath(root)) {
|
|
74
78
|
const st = lstatSync(dest, { throwIfNoEntry: false });
|
|
75
79
|
if (st) {
|
|
76
80
|
if (st.isSymbolicLink()) return false;
|
|
@@ -86,8 +90,8 @@ export function safeLeaf(root, dest) {
|
|
|
86
90
|
* callers that already have it (every one of them, since it is what they are about to push
|
|
87
91
|
* into `owned`) pass it straight through instead of re-deriving it from `dest`.
|
|
88
92
|
*/
|
|
89
|
-
export function claimLeaf(root, dest, rel) {
|
|
90
|
-
if (safeLeaf(root, dest)) return true;
|
|
93
|
+
export function claimLeaf(root, dest, rel, rroot = undefined) {
|
|
94
|
+
if (safeLeaf(root, dest, rroot)) return true;
|
|
91
95
|
process.stderr.write(`[geneseed] refused ${rel}: it is a link — Geneseed writes only `
|
|
92
96
|
+ 'regular files it owns\n');
|
|
93
97
|
return false;
|
|
@@ -107,6 +111,19 @@ export function fileHash(p) {
|
|
|
107
111
|
return createHash('sha256').update(folded, 'latin1').digest('hex');
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
/**
|
|
115
|
+
* `fileHash` of a file whose whole content is `written`, computed from the string instead of
|
|
116
|
+
* read back — `written` must be EXACTLY what went to disk as utf8 (`writeText`'s translated
|
|
117
|
+
* text, never the text before its `\n` -> `os.EOL`: an embedded `\r\n` becomes `\r\r\n` on
|
|
118
|
+
* Windows, which folds to `\r\n`, not `\n`). CR and LF are single bytes that never occur
|
|
119
|
+
* inside a multi-byte utf8 sequence, so folding the string then encoding equals encoding then
|
|
120
|
+
* folding.
|
|
121
|
+
*/
|
|
122
|
+
export function textHash(written) {
|
|
123
|
+
return createHash('sha256').update(Buffer.from(written.replaceAll('\r\n', '\n'), 'utf8'))
|
|
124
|
+
.digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
/**
|
|
111
128
|
* One sha256 per entry of `owned` that is both inside `root` (through `ownedWithin`, the same
|
|
112
129
|
* containment boundary every other owned-rel walk goes through) and a file that still exists —
|
|
@@ -114,12 +131,23 @@ export function fileHash(p) {
|
|
|
114
131
|
* rather than hashed. Keys land in the same sorted order the manifest writers already give
|
|
115
132
|
* `owned` itself (`[...owned].sort()`), so the hash map a re-emit produces for an unchanged
|
|
116
133
|
* tree is byte-identical to the one before it — `golden.mjs --idempotent`'s whole point.
|
|
134
|
+
*
|
|
135
|
+
* A file this emit wrote through `writeText` is hashed from the text it recorded
|
|
136
|
+
* (`textWrites`, `js/lib/fs.mjs`) rather than read back, while its size and mtime still match
|
|
137
|
+
* the stat taken at write time; anything else — a copied binary, an unrecorded rewrite — is read.
|
|
138
|
+
* shortcut: a same-size rewrite by a non-`writeText` writer inside one mtime tick would keep the
|
|
139
|
+
* stale record; no emit does that today (measured across golden's cells, 0 mismatches) — route
|
|
140
|
+
* such a writer through `writeText` or drop its entry if one ever appears.
|
|
117
141
|
*/
|
|
118
142
|
export function ownedHashes(root, owned) {
|
|
119
143
|
const hashes = {};
|
|
120
144
|
for (const rel of ownedWithin(root, owned).sort()) {
|
|
121
145
|
const p = path.join(root, rel);
|
|
122
|
-
|
|
146
|
+
const st = statOrNull(p);
|
|
147
|
+
if (!st?.isFile()) continue;
|
|
148
|
+
const w = textWrites.map?.get(path.resolve(p));
|
|
149
|
+
hashes[rel] = w && w.size === st.size && w.mtimeMs === st.mtimeMs ? textHash(w.text)
|
|
150
|
+
: fileHash(p);
|
|
123
151
|
}
|
|
124
152
|
return hashes;
|
|
125
153
|
}
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -104,7 +104,7 @@ const consentRuleOn = (d, excluded = []) =>
|
|
|
104
104
|
*
|
|
105
105
|
* `rm -rf *` WENT TOO (user ruling, 2026-10-17): a blanket ask on every recursive removal is the
|
|
106
106
|
* glob-cannot-read-state problem again — the guard's `lossReason` lets an `rm` of tracked,
|
|
107
|
-
* unmodified files through and blocks one git cannot restore, and its `
|
|
107
|
+
* unmodified files through and blocks one git cannot restore, and its `catastrophic()` still
|
|
108
108
|
* blocks `rm -rf /`, `~` and the drive-root forms outright. Same stranded-key handling as above:
|
|
109
109
|
* an existing `opencode.json` keeps its `rm -rf *` key, asking, until the user deletes it.
|
|
110
110
|
*
|
|
@@ -726,7 +726,9 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
|
|
|
726
726
|
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
727
727
|
*/
|
|
728
728
|
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
|
|
729
|
-
|
|
729
|
+
gates = {}) {
|
|
730
|
+
// One object keyed as `GATE_SWITCHES` keys it; only an explicit `false` disarms a gate.
|
|
731
|
+
const { secretsGate = true, doneGate = true, deletionGate = true } = gates ?? {};
|
|
730
732
|
const run = hookPrefix(hookOpts);
|
|
731
733
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
732
734
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
|
|
@@ -932,15 +934,14 @@ function readSettingsForMerge(p, consequence) {
|
|
|
932
934
|
* global that never stood down for a project install.
|
|
933
935
|
*/
|
|
934
936
|
export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
|
|
935
|
-
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p),
|
|
936
|
-
gates = {}) {
|
|
937
|
+
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), gates = {}) {
|
|
937
938
|
const prior = (priorHooks || []).filter(isDict);
|
|
938
939
|
const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
|
|
939
940
|
if (!loaded) return [p, prior];
|
|
940
941
|
const [config, hadComments] = loaded;
|
|
941
942
|
let hooks = get(config, 'hooks');
|
|
942
943
|
if (!isDict(hooks)) hooks = {};
|
|
943
|
-
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host,
|
|
944
|
+
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, gates);
|
|
944
945
|
const canonFlat = [];
|
|
945
946
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
946
947
|
for (const g of groups) canonFlat.push({ event, group: g });
|
|
@@ -185,7 +185,7 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
|
|
|
185
185
|
// ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
|
|
186
186
|
|
|
187
187
|
const GUARD_PLUGIN = 'geneseed-guard.js';
|
|
188
|
-
/** The guard blocks catastrophic shell (`
|
|
188
|
+
/** The guard blocks catastrophic shell (`catastrophic()`); destructive git is the permission
|
|
189
189
|
* prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
|
|
190
190
|
const GUARD_BAD_COMMAND = 'rm -rf /';
|
|
191
191
|
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
|
|
10
10
|
* `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
|
|
11
11
|
* which the smoke test does not, so the two agree on any carrier without one.
|
|
12
|
-
* - `skills` the name + description every native skill lists
|
|
13
|
-
*
|
|
12
|
+
* - `skills` the name + description of every native skill the MODEL lists. A user-only skill
|
|
13
|
+
* (and every alias, which native.mjs writes user-only) stays on disk but out of
|
|
14
|
+
* the catalogue, by each host's own mechanism (`userOnly`): the frontmatter's
|
|
15
|
+
* `disable-model-invocation` on Claude Code and OpenClaude; on OpenCode, which
|
|
16
|
+
* ignores that key, the `permission.skill` deny in `opencode.json`; on Bob no
|
|
17
|
+
* SKILL.md exists for one, so there is nothing to skip.
|
|
14
18
|
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
15
19
|
* - `context` the session files the context hook injects (untouched seeds already skipped).
|
|
16
20
|
* The per-repo project context is NOT counted: it changes with the working dir.
|
|
@@ -21,6 +25,8 @@ import { sessionFiles } from '../hosts/hooks-context.mjs';
|
|
|
21
25
|
import { carriersFor } from '../hosts/installs.mjs';
|
|
22
26
|
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
23
27
|
import { listDir } from '../hosts/hooks-prims.mjs';
|
|
28
|
+
import { loadJsonObject, opencodeTarget } from '../hosts/settings.mjs';
|
|
29
|
+
import { isDict } from '../lib/json.mjs';
|
|
24
30
|
import { isFile, readText } from '../lib/fs.mjs';
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -44,14 +50,39 @@ function fmValue(raw) {
|
|
|
44
50
|
return raw;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
53
|
+
/**
|
|
54
|
+
* name + description of one spec file; `fallbackName` for agents, whose name is the file stem.
|
|
55
|
+
* `hidden(fm)` answers true for a file the host keeps out of the model's catalogue: it counts 0.
|
|
56
|
+
* @param {string} file
|
|
57
|
+
* @param {string} fallbackName
|
|
58
|
+
* @param {(fm: Map<string, string>) => boolean} [hidden]
|
|
59
|
+
*/
|
|
60
|
+
function listedChars(file, fallbackName, hidden = () => false) {
|
|
49
61
|
let fm;
|
|
50
62
|
try { [fm] = frontmatter(readText(file)); } catch { return 0; }
|
|
63
|
+
if (hidden(fm)) return 0;
|
|
51
64
|
const name = fmValue(fm.get('name')) || fallbackName;
|
|
52
65
|
return name.length + fmValue(fm.get('description')).length;
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How `host` hides a skill from the model, mirroring `writeNativeLayer` (js/hosts/native.mjs).
|
|
70
|
+
* OpenCode reads the deny where `mcpConfigFor` finds its config (`opencode.json(c)` at the
|
|
71
|
+
* root); a blanket non-object `permission.skill` is the user's own policy and hides nothing
|
|
72
|
+
* by name, so nothing is skipped. Bob documents no such key: nothing is skipped either.
|
|
73
|
+
*/
|
|
74
|
+
function userOnlyTest(host, root) {
|
|
75
|
+
if (host === 'claude' || host === 'openclaude') {
|
|
76
|
+
return (_name, fm) => fm.get('disable-model-invocation') === 'true';
|
|
77
|
+
}
|
|
78
|
+
if (host === 'opencode') {
|
|
79
|
+
const { data } = loadJsonObject(opencodeTarget(path.join(root, 'opencode.json')));
|
|
80
|
+
const skill = isDict(data) && isDict(data.permission) ? data.permission.skill : null;
|
|
81
|
+
return (name) => isDict(skill) && skill[name] === 'deny';
|
|
82
|
+
}
|
|
83
|
+
return () => false;
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/**
|
|
56
87
|
* `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
|
|
57
88
|
* `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
|
|
@@ -64,9 +95,10 @@ export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
|
64
95
|
|
|
65
96
|
let skills = 0;
|
|
66
97
|
const skillsDir = path.join(dir, 'skills');
|
|
98
|
+
const userOnly = userOnlyTest(host, root);
|
|
67
99
|
for (const name of listDir(skillsDir)) {
|
|
68
100
|
const f = path.join(skillsDir, name, 'SKILL.md');
|
|
69
|
-
if (isFile(f)) skills += listedChars(f, name);
|
|
101
|
+
if (isFile(f)) skills += listedChars(f, name, (fm) => userOnly(name, fm));
|
|
70
102
|
}
|
|
71
103
|
let agents = 0;
|
|
72
104
|
if (host !== 'bob') {
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -35,11 +35,15 @@
|
|
|
35
35
|
* directly rather than by comparison, because a check made tolerant of a value owes one
|
|
36
36
|
* somewhere else.
|
|
37
37
|
*/
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
38
39
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
39
40
|
import os from 'node:os';
|
|
40
41
|
import path from 'node:path';
|
|
41
42
|
|
|
42
|
-
import { emitGlobalInto } from '../build/driver.mjs';
|
|
43
|
+
import { PRIMARY_AGENT_SRC, emitGlobalInto } from '../build/driver.mjs';
|
|
44
|
+
import { rglobFiles } from '../build/emit-common.mjs';
|
|
45
|
+
import { CONFIG, makeCfg } from '../build/source.mjs';
|
|
46
|
+
import { sourceFingerprint } from '../build/version.mjs';
|
|
43
47
|
import {
|
|
44
48
|
GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
|
|
45
49
|
} from '../hosts/hosts.mjs';
|
|
@@ -50,9 +54,11 @@ import {
|
|
|
50
54
|
trustOfDir,
|
|
51
55
|
readJsonMaybe, readMaybe, themeOfDir,
|
|
52
56
|
} from '../hosts/installs.mjs';
|
|
57
|
+
import { safeLeaf } from '../hosts/owned.mjs';
|
|
53
58
|
import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
|
|
54
59
|
import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
|
|
55
60
|
import { toPlatformPath } from '../lib/paths.mjs';
|
|
61
|
+
import { relPosix } from '../lib/text.mjs';
|
|
56
62
|
|
|
57
63
|
/**
|
|
58
64
|
* `_harness_core._T0` — this process's start, in epoch seconds.
|
|
@@ -121,6 +127,81 @@ export function withStdoutSwallowed(fn) {
|
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
/** A manifest's `rel`, spelled the way the expected snapshot keys it. */
|
|
131
|
+
const posixRel = (rel) => rel.split('\\').join('/');
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render the `expected` tree for `host` at `axes` into a temp dir and SNAPSHOT it — every file,
|
|
135
|
+
* not only the owned ones, because a rel the DEPLOYED manifest claims is looked up here too and
|
|
136
|
+
* its presence decides "edited" against "added". The temp dir is gone before this returns.
|
|
137
|
+
*
|
|
138
|
+
* The one place a render depends on WHERE it is installed: a global Bob emit writes each
|
|
139
|
+
* folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
|
|
140
|
+
* reference render names the temp tree. Mapped back to the deployed dir here, or every such
|
|
141
|
+
* skill reads as edited. A deployment that has since MOVED still differs, and that is real
|
|
142
|
+
* drift — its skills point at a directory that is no longer theirs. A side-file pointer (and a
|
|
143
|
+
* global OpenCode command's) spells the same directory `/`-separated, so that spelling is
|
|
144
|
+
* mapped too.
|
|
145
|
+
*/
|
|
146
|
+
function renderExpected(host, axes, deployedDir) {
|
|
147
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-diff-'));
|
|
148
|
+
try {
|
|
149
|
+
const expected = path.join(tmp, 'expected');
|
|
150
|
+
withStdoutSwallowed(() => emitGlobalInto(host, {
|
|
151
|
+
...axes, out: path.join(tmp, 'bundle'), cfgDir: expected,
|
|
152
|
+
}));
|
|
153
|
+
const posix = (p) => p.split(path.sep).join('/');
|
|
154
|
+
const texts = new Map();
|
|
155
|
+
for (const p of rglobFiles(expected)) {
|
|
156
|
+
texts.set(relPosix(expected, p), readText(p).split(expected).join(deployedDir)
|
|
157
|
+
.split(posix(expected)).join(posix(deployedDir)));
|
|
158
|
+
}
|
|
159
|
+
return { owned: ownedSet(expected), texts };
|
|
160
|
+
} finally {
|
|
161
|
+
// `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
|
|
162
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A memo for `diffCollect`'s `expected` render — opt-in, so `geneseed diff` and every other
|
|
168
|
+
* caller still renders afresh. The web console passes one: `/api/overview` and `/api/fleet`
|
|
169
|
+
* both diff the selected install on every refresh, and the render is ~0.6 s of each.
|
|
170
|
+
*
|
|
171
|
+
* ONLY THE RENDER IS MEMOISED, NEVER THE RESULT: the compare against the deployed tree runs on
|
|
172
|
+
* every call, because catching a hand edit there is the whole point of the drift figure.
|
|
173
|
+
* One entry per deployed dir, at most `cap` of them (least recently used goes first); `renders`
|
|
174
|
+
* counts real renders, for the tests.
|
|
175
|
+
*/
|
|
176
|
+
export function renderMemo(cap = 4) {
|
|
177
|
+
return { cap, renders: 0, entries: new Map() };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The memoised `renderExpected`. The key is everything the render reads: the source
|
|
182
|
+
* fingerprint, the two render inputs it does not hash (`harness.config.json` and the
|
|
183
|
+
* orchestrator agent), the host and every axis. A deployed dir whose key changed is
|
|
184
|
+
* re-rendered in place, and a fingerprint change drops every entry from the old source.
|
|
185
|
+
*/
|
|
186
|
+
function memoExpected(memo, host, axes, deployedDir, sourceFp) {
|
|
187
|
+
const fp = sourceFp ?? sourceFingerprint(makeCfg());
|
|
188
|
+
const extra = createHash('sha256');
|
|
189
|
+
for (const p of [CONFIG, PRIMARY_AGENT_SRC]) extra.update(`${readMaybe(p) ?? ''}\0`);
|
|
190
|
+
const key = JSON.stringify([fp, extra.digest('hex'), host, axes]);
|
|
191
|
+
const hit = memo.entries.get(deployedDir);
|
|
192
|
+
memo.entries.delete(deployedDir);
|
|
193
|
+
if (hit && hit.key === key) {
|
|
194
|
+
memo.entries.set(deployedDir, hit);
|
|
195
|
+
return hit.exp;
|
|
196
|
+
}
|
|
197
|
+
for (const [d, e] of memo.entries) if (e.fp !== fp) memo.entries.delete(d);
|
|
198
|
+
const exp = renderExpected(host, axes, deployedDir);
|
|
199
|
+
memo.renders += 1;
|
|
200
|
+
memo.entries.set(deployedDir, { key, fp, exp });
|
|
201
|
+
while (memo.entries.size > memo.cap) memo.entries.delete(memo.entries.keys().next().value);
|
|
202
|
+
return exp;
|
|
203
|
+
}
|
|
204
|
+
|
|
124
205
|
/**
|
|
125
206
|
* `_harness_diff._diff_collect` — returns `{target, theme, files}`, with `files === null` for
|
|
126
207
|
* "nothing to compare here".
|
|
@@ -140,7 +221,9 @@ export function withStdoutSwallowed(fn) {
|
|
|
140
221
|
* choice, which is the same defect with teeth. `rebuild-all` never had it: it reads
|
|
141
222
|
* `postureOfDir`/`modeOfDir` per install root, and this now asks the same two questions.
|
|
142
223
|
*/
|
|
143
|
-
export function diffCollect({
|
|
224
|
+
export function diffCollect({
|
|
225
|
+
target = null, theme = null, emit = null, footprint = null, memo = null, sourceFp = null,
|
|
226
|
+
} = {}) {
|
|
144
227
|
// `Path(target).expanduser()` — expanded and NOT resolved, which is the opposite of
|
|
145
228
|
// `cmd_version`'s `.expanduser().resolve()` two files over. It is observable: `target` is
|
|
146
229
|
// printed on the summary line, so `--target .` reports `.` here and an absolute path
|
|
@@ -165,69 +248,66 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
|
|
|
165
248
|
|
|
166
249
|
const fp = footprint || footprintOfDir(dir);
|
|
167
250
|
|
|
251
|
+
// Every input the render reads off the deployment, in ONE object: it is what the render is
|
|
252
|
+
// called with AND what the memo keys on, so the two cannot drift apart.
|
|
253
|
+
const axes = {
|
|
254
|
+
theme: themeName, footprint: fp,
|
|
255
|
+
// The third register read off the deployment, for the same reason as the other two: an
|
|
256
|
+
// install built with `--doctrines craft` compared against an `expected` rendered at all
|
|
257
|
+
// four reports its entire doctrines section — and its `Active packs:` line — as drift.
|
|
258
|
+
// `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
|
|
259
|
+
// makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
|
|
260
|
+
posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
|
|
261
|
+
// The loop skill's preset, or its `Default trust preset:` line reads as edited.
|
|
262
|
+
trust: trustOfDir(dir, host),
|
|
263
|
+
// The excluded rules, or an install built `--exclude-rules "process 5"` reads its
|
|
264
|
+
// carrier as drift: `expected` would re-state the rule the deployment took away.
|
|
265
|
+
excludeRules: excludedRulesOfDir(dir),
|
|
266
|
+
// The excluded skills, or every skill the install left out reads as deleted.
|
|
267
|
+
excludeSkills: excludedSkillsOfDir(dir),
|
|
268
|
+
// The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
|
|
269
|
+
...gatesOfDir(dir),
|
|
270
|
+
};
|
|
271
|
+
const deployedDir = resolvePath(dir);
|
|
272
|
+
const exp = memo
|
|
273
|
+
? memoExpected(memo, host, axes, deployedDir, sourceFp)
|
|
274
|
+
: renderExpected(host, axes, deployedDir);
|
|
275
|
+
|
|
168
276
|
const files = [];
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
trust: trustOfDir(dir, host),
|
|
182
|
-
// The excluded rules, or an install built `--exclude-rules "process 5"` reads its
|
|
183
|
-
// carrier as drift: `expected` would re-state the rule the deployment took away.
|
|
184
|
-
excludeRules: excludedRulesOfDir(dir),
|
|
185
|
-
// The excluded skills, or every skill the install left out reads as deleted.
|
|
186
|
-
excludeSkills: excludedSkillsOfDir(dir),
|
|
187
|
-
// The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
|
|
188
|
-
...gatesOfDir(dir),
|
|
189
|
-
}));
|
|
190
|
-
// The one place a render depends on WHERE it is installed: a global Bob emit writes each
|
|
191
|
-
// folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
|
|
192
|
-
// reference render names the temp tree. Mapped back to the deployed dir before comparing,
|
|
193
|
-
// or every such skill reads as edited. A deployment that has since MOVED still differs,
|
|
194
|
-
// and that is real drift — its skills point at a directory that is no longer theirs.
|
|
195
|
-
// A side-file pointer (and a global OpenCode command's) spells the same directory
|
|
196
|
-
// `/`-separated, so that spelling is mapped too.
|
|
197
|
-
const deployedDir = resolvePath(dir);
|
|
198
|
-
const posix = (p) => p.split(path.sep).join('/');
|
|
199
|
-
const rels = [...new Set([...ownedSet(dir), ...ownedSet(expected)])].sort();
|
|
200
|
-
for (const rel of rels) {
|
|
201
|
-
const a = path.join(dir, rel);
|
|
202
|
-
const b = path.join(expected, rel);
|
|
203
|
-
if (isFile(a) && isFile(b)) {
|
|
204
|
-
// `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
|
|
205
|
-
// same bytes, and `readText` folds the line endings the way `Path.read_text` does.
|
|
206
|
-
const ta = readText(a);
|
|
207
|
-
const tb = readText(b).split(expected).join(deployedDir)
|
|
208
|
-
.split(posix(expected)).join(posix(deployedDir));
|
|
209
|
-
if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
|
|
210
|
-
const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
|
|
211
|
-
fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
|
|
212
|
-
});
|
|
213
|
-
files.push({ rel, status: 'edited', diff });
|
|
214
|
-
}
|
|
215
|
-
} else if (isFile(a)) {
|
|
216
|
-
const body = splitLines(readText(a));
|
|
217
|
-
files.push({
|
|
218
|
-
rel,
|
|
219
|
-
status: 'added',
|
|
220
|
-
diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
|
|
221
|
-
});
|
|
222
|
-
} else {
|
|
223
|
-
files.push({
|
|
224
|
-
rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'],
|
|
277
|
+
const rels = [...new Set([...ownedSet(dir), ...exp.owned])].sort();
|
|
278
|
+
for (const rel of rels) {
|
|
279
|
+
const a = path.join(dir, rel);
|
|
280
|
+
const tb = exp.texts.get(posixRel(rel));
|
|
281
|
+
let rec = null;
|
|
282
|
+
if (isFile(a) && tb !== undefined) {
|
|
283
|
+
// `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
|
|
284
|
+
// same bytes, and `readText` folds the line endings the way `Path.read_text` does.
|
|
285
|
+
const ta = readText(a);
|
|
286
|
+
if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
|
|
287
|
+
const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
|
|
288
|
+
fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
|
|
225
289
|
});
|
|
290
|
+
rec = { rel, status: 'edited', diff };
|
|
226
291
|
}
|
|
292
|
+
} else if (isFile(a)) {
|
|
293
|
+
const body = splitLines(readText(a));
|
|
294
|
+
rec = {
|
|
295
|
+
rel,
|
|
296
|
+
status: 'added',
|
|
297
|
+
diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
|
|
298
|
+
};
|
|
299
|
+
} else {
|
|
300
|
+
rec = { rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'] };
|
|
227
301
|
}
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
|
|
302
|
+
if (rec === null) continue;
|
|
303
|
+
// A path that is, or sits under, a link the emit refused to write through (a skill dir the
|
|
304
|
+
// user junctioned to their own copy) is theirs: no rebuild can clear it, so it is not drift.
|
|
305
|
+
// `safeLeaf` is the claimer's own test, so "linked" here is exactly "refused" there — and
|
|
306
|
+
// it runs only on a path that differs, because it lstats and resolves.
|
|
307
|
+
if (!safeLeaf(dir, a, deployedDir)) {
|
|
308
|
+
rec = { rel, status: 'linked', diff: ['(a link — yours; Geneseed leaves it alone)'] };
|
|
309
|
+
}
|
|
310
|
+
files.push(rec);
|
|
231
311
|
}
|
|
232
312
|
// Already sorted by `rel` above; the Python sorts again after the loop and so does this,
|
|
233
313
|
// because the two sorts are over different things — that one is over the RECORDS, and a
|
|
@@ -236,8 +316,13 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
|
|
|
236
316
|
return { target: dir, theme: themeName, files };
|
|
237
317
|
}
|
|
238
318
|
|
|
319
|
+
/** The records that are drift: everything but a `linked` path, which is the user's own. */
|
|
320
|
+
export const drifted = (files) => files.filter((f) => f.status !== 'linked');
|
|
321
|
+
|
|
239
322
|
/** `_harness_diff._improvements_md` — pure; `when` is the caller's timestamp. */
|
|
240
|
-
export function improvementsMd(target, theme,
|
|
323
|
+
export function improvementsMd(target, theme, allFiles, when) {
|
|
324
|
+
// A linked path is never a back-port item: the file is the user's, not a local edit of ours.
|
|
325
|
+
const files = drifted(allFiles);
|
|
241
326
|
const edited = files.filter((f) => f.status === 'edited');
|
|
242
327
|
const added = files.filter((f) => f.status === 'added');
|
|
243
328
|
const missing = files.filter((f) => f.status === 'missing');
|
|
@@ -327,7 +412,8 @@ export function exportImprovements(target = null, theme = null, outPath = null)
|
|
|
327
412
|
const collected = diffCollect({ target, theme });
|
|
328
413
|
// Python's `if not files` — null AND the empty list both return, which is why the caller
|
|
329
414
|
// gets `files` back rather than a bare path and can tell the two apart itself.
|
|
330
|
-
|
|
415
|
+
// A linked path alone is not drift, or every update would export an empty report.
|
|
416
|
+
if (!collected.files || drifted(collected.files).length === 0) return [null, collected.files];
|
|
331
417
|
return [writeImprovements(collected.target, collected.theme, collected.files, outPath),
|
|
332
418
|
collected.files];
|
|
333
419
|
}
|
|
@@ -383,14 +469,18 @@ export function cmdDiff(args) {
|
|
|
383
469
|
const edited = files.filter((f) => f.status === 'edited');
|
|
384
470
|
const added = files.filter((f) => f.status === 'added');
|
|
385
471
|
const missing = files.filter((f) => f.status === 'missing');
|
|
472
|
+
const linked = files.filter((f) => f.status === 'linked');
|
|
386
473
|
printOut(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
|
|
387
474
|
printOut(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
|
|
388
475
|
+ `${missing.length} missing-from-deployed\n`);
|
|
476
|
+
// Its own line, printed only when there is one, so the summary above is unchanged otherwise.
|
|
477
|
+
if (linked.length) printOut(`[diff] ${linked.length} linked (yours, left alone)\n`);
|
|
389
478
|
for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
|
|
390
479
|
for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
|
|
391
480
|
for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
|
|
481
|
+
for (const f of linked) printOut(` = ${f.rel} (a link — yours, left alone)\n`);
|
|
392
482
|
if (args.out) {
|
|
393
|
-
if (files.length) {
|
|
483
|
+
if (drifted(files).length) {
|
|
394
484
|
// Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
|
|
395
485
|
// path". `parse()` gives a flag `true` and an option a string, so the two arrive
|
|
396
486
|
// distinguishable here without a third kind of entry in the VERBS table.
|
package/js/inspect/validate.mjs
CHANGED
|
@@ -130,8 +130,8 @@ export function cmdValidate(args) {
|
|
|
130
130
|
const opts = {
|
|
131
131
|
theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
|
|
132
132
|
trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
|
|
133
|
-
excludeSkills: args.excludeSkills,
|
|
134
|
-
|
|
133
|
+
excludeSkills: args.excludeSkills,
|
|
134
|
+
...Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, args[g.arg] ? false : null])),
|
|
135
135
|
};
|
|
136
136
|
let scanDirs;
|
|
137
137
|
try {
|
package/js/lib/fs.mjs
CHANGED
|
@@ -29,8 +29,32 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import {
|
|
31
31
|
appendFileSync, writeFileSync, readFileSync, copyFileSync, statSync, utimesSync,
|
|
32
|
+
openSync, fstatSync, readSync, closeSync,
|
|
32
33
|
} from 'node:fs';
|
|
33
34
|
import { EOL } from 'node:os';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What `writeText` wrote while an emit has armed this (`withWriteRecord` below, around the
|
|
39
|
+
* three manifest-writing emits in `js/build/driver.mjs`): resolved path ->
|
|
40
|
+
* `{ text, size, mtimeMs }`, the translated text and a stat taken right after the write.
|
|
41
|
+
* `ownedHashes` (`js/hosts/owned.mjs`) hashes that text instead of reading the file back —
|
|
42
|
+
* the read-back was ~25% of `doctor --all` — and only while the file's size and mtime still
|
|
43
|
+
* match, so a later rewrite by any writer that does not record (a raw `writeFileSync`, a
|
|
44
|
+
* rename) falls back to the read. `copyFile` and `appendText` drop the entry outright.
|
|
45
|
+
* `null`, the default, records nothing: no other caller pays the stat or retains the text.
|
|
46
|
+
*/
|
|
47
|
+
export const textWrites = { map: null };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run one emit with `textWrites` armed, and disarm it on EVERY exit: an emit that throws inside
|
|
51
|
+
* a long-lived process (the web daemon emits in-process) would otherwise leave every later
|
|
52
|
+
* `writeText` paying a stat and keeping its text in memory.
|
|
53
|
+
*/
|
|
54
|
+
export function withWriteRecord(fn) {
|
|
55
|
+
textWrites.map = new Map();
|
|
56
|
+
try { return fn(); } finally { textWrites.map = null; }
|
|
57
|
+
}
|
|
34
58
|
/**
|
|
35
59
|
* `Path.write_text(text, encoding="utf-8")`.
|
|
36
60
|
*
|
|
@@ -49,7 +73,12 @@ import { EOL } from 'node:os';
|
|
|
49
73
|
* The same vendored skill folder can contain files from both categories.
|
|
50
74
|
*/
|
|
51
75
|
export function writeText(path, text) {
|
|
52
|
-
|
|
76
|
+
const out = EOL === '\n' ? text : text.replaceAll('\n', EOL);
|
|
77
|
+
writeFileSync(path, out, 'utf8');
|
|
78
|
+
if (textWrites.map) {
|
|
79
|
+
const st = statSync(path);
|
|
80
|
+
textWrites.map.set(resolve(path), { text: out, size: st.size, mtimeMs: st.mtimeMs });
|
|
81
|
+
}
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
/**
|
|
@@ -62,6 +91,7 @@ export function writeText(path, text) {
|
|
|
62
91
|
* invisible in an editor and a whole-file difference to the gate.
|
|
63
92
|
*/
|
|
64
93
|
export function appendText(path, text) {
|
|
94
|
+
textWrites.map?.delete(resolve(path));
|
|
65
95
|
appendFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
|
|
66
96
|
}
|
|
67
97
|
|
|
@@ -79,6 +109,46 @@ export function readText(path) {
|
|
|
79
109
|
return readFileSync(path, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
80
110
|
}
|
|
81
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The last `bytes` of a file, decoded as `readText` decodes it, and the byte offset the window
|
|
114
|
+
* began at (0: the whole file). A window that starts mid-line drops that partial line, cut at a
|
|
115
|
+
* newline BYTE before decoding — so a multibyte character the window split falls in the dropped
|
|
116
|
+
* part, and a `\r\n` split between its two bytes leaves only an empty line. One byte before the
|
|
117
|
+
* window is read too, so a window that starts exactly on a line keeps it. Throws as
|
|
118
|
+
* `readFileSync` does (missing, unreadable, a directory).
|
|
119
|
+
*/
|
|
120
|
+
export function readTail(path, bytes) {
|
|
121
|
+
const fd = openSync(path, 'r');
|
|
122
|
+
try {
|
|
123
|
+
const st = fstatSync(fd);
|
|
124
|
+
// Windows opens a directory without complaint and reports size 0: refuse it as readFileSync does.
|
|
125
|
+
if (st.isDirectory()) {
|
|
126
|
+
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${path}'`),
|
|
127
|
+
{ code: 'EISDIR' });
|
|
128
|
+
}
|
|
129
|
+
const size = st.size;
|
|
130
|
+
const start = Math.max(0, size - bytes);
|
|
131
|
+
const from = start > 0 ? start - 1 : 0;
|
|
132
|
+
const buf = Buffer.alloc(size - from);
|
|
133
|
+
let got = 0;
|
|
134
|
+
while (got < buf.length) {
|
|
135
|
+
const n = readSync(fd, buf, got, buf.length - got, from + got);
|
|
136
|
+
if (!n) break;
|
|
137
|
+
got += n;
|
|
138
|
+
}
|
|
139
|
+
let cut = 0;
|
|
140
|
+
if (start > 0) {
|
|
141
|
+
const read = buf.subarray(0, got);
|
|
142
|
+
const ends = [read.indexOf(0x0a), read.indexOf(0x0d)].filter((i) => i !== -1);
|
|
143
|
+
cut = ends.length ? Math.min(...ends) + 1 : got; // no line end: nothing whole to decode
|
|
144
|
+
}
|
|
145
|
+
const text = buf.toString('utf8', cut, got).replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
146
|
+
return { text, start };
|
|
147
|
+
} finally {
|
|
148
|
+
closeSync(fd);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
82
152
|
/**
|
|
83
153
|
* `shutil.copy2(src, dest)`.
|
|
84
154
|
*
|
|
@@ -94,6 +164,7 @@ export function readText(path) {
|
|
|
94
164
|
* mode the Python side has and nothing depends on. Mode is not copied here.
|
|
95
165
|
*/
|
|
96
166
|
export function copyFile(src, dest) {
|
|
167
|
+
textWrites.map?.delete(resolve(dest));
|
|
97
168
|
copyFileSync(src, dest);
|
|
98
169
|
const st = statSync(src);
|
|
99
170
|
utimesSync(dest, st.atime, st.mtime);
|
|
@@ -216,7 +287,7 @@ export function withPlatformNewlines(fn) {
|
|
|
216
287
|
*/
|
|
217
288
|
const NOT_THERE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP', 'EBADF']);
|
|
218
289
|
|
|
219
|
-
function statOrNull(p) {
|
|
290
|
+
export function statOrNull(p) {
|
|
220
291
|
try {
|
|
221
292
|
return statSync(p);
|
|
222
293
|
} catch (e) {
|