vexp-cli 3.2.5 → 3.3.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.
@@ -0,0 +1,244 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { TextDecoder } from "util";
4
+ import { vexpHome } from "./state-home.js";
5
+ import { canonicalWorkspaceRoot } from "./socket-path.js";
6
+ export function agentConsentPath() {
7
+ return path.join(vexpHome(), ".vexp", "agent-consent.json");
8
+ }
9
+ /** The key a workspace's answers live under (see the header). */
10
+ export function consentKey(root, opts = {}) {
11
+ const platform = opts.platform ?? process.platform;
12
+ if (platform !== "win32")
13
+ return path.resolve(root);
14
+ let canonical = root;
15
+ if (opts.realpath) {
16
+ try {
17
+ canonical = opts.realpath(root);
18
+ }
19
+ catch {
20
+ /* not resolvable: the spelling given, as canonicalWorkspaceRoot does */
21
+ }
22
+ }
23
+ else {
24
+ canonical = canonicalWorkspaceRoot(root);
25
+ }
26
+ return canonical.toLowerCase();
27
+ }
28
+ function isPlainObject(v) {
29
+ return !!v && typeof v === "object" && !Array.isArray(v);
30
+ }
31
+ /** Anything that is not the schema reads as "no answers yet". */
32
+ function normalize(raw) {
33
+ const obj = isPlainObject(raw) ? { ...raw } : {};
34
+ const workspaces = {};
35
+ const ws = obj.workspaces;
36
+ if (isPlainObject(ws)) {
37
+ for (const [key, agents] of Object.entries(ws)) {
38
+ if (!isPlainObject(agents))
39
+ continue;
40
+ const kept = {};
41
+ for (const [agent, decision] of Object.entries(agents)) {
42
+ if (typeof decision === "string")
43
+ kept[agent] = decision;
44
+ }
45
+ workspaces[key] = kept;
46
+ }
47
+ }
48
+ const never = Array.isArray(obj.never)
49
+ ? obj.never.filter((a) => typeof a === "string")
50
+ : [];
51
+ return { ...obj, version: typeof obj.version === "number" ? obj.version : 1, workspaces, never };
52
+ }
53
+ /**
54
+ * The file's bytes as text, by the rules of the engine's decode_config_bytes
55
+ * (vexp-core config.rs): a UTF-8 BOM is dropped, a UTF-16 LE or BE BOM
56
+ * decodes the rest as UTF-16, anything else must be UTF-8. The same hands
57
+ * that edit vexp.toml edit this file, and Windows PowerShell 5.1's `>` and
58
+ * Out-File write UTF-16LE with a BOM (field report, 2026-09-24: the
59
+ * customer's vexp.toml). Read as UTF-8, such a file was "no answers", and
60
+ * the next answer overwrote every one saved in it (review, 2026-09-24).
61
+ * Throws with the problem, worded as the engine words it.
62
+ */
63
+ export function decodeConsentBytes(bytes) {
64
+ const utf8 = (b, problem) => {
65
+ try {
66
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(b);
67
+ }
68
+ catch {
69
+ throw new Error(problem);
70
+ }
71
+ };
72
+ const utf16 = (rest, littleEndian) => {
73
+ if (rest.length % 2 !== 0)
74
+ throw new Error("is UTF-16 with a truncated last character");
75
+ const le = Buffer.from(rest);
76
+ if (!littleEndian)
77
+ le.swap16();
78
+ const text = le.toString("utf16le");
79
+ // The engine's String::from_utf16 refuses an unpaired surrogate.
80
+ if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text)) {
81
+ throw new Error("is not valid UTF-16 text");
82
+ }
83
+ return text;
84
+ };
85
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
86
+ return utf8(bytes.subarray(3), "is not valid UTF-8");
87
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
88
+ return utf16(bytes.subarray(2), true);
89
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
90
+ return utf16(bytes.subarray(2), false);
91
+ return utf8(bytes, "is not UTF-8 text");
92
+ }
93
+ export function loadAgentConsentFile(file = agentConsentPath()) {
94
+ let bytes;
95
+ try {
96
+ bytes = fs.readFileSync(file);
97
+ }
98
+ catch (e) {
99
+ const code = e.code;
100
+ // ENOTDIR: a file where the .vexp folder belongs. Nothing is saved
101
+ // there, and nothing can be.
102
+ if (code === "ENOENT" || code === "ENOTDIR")
103
+ return { state: "absent" };
104
+ return { state: "unreadable", problem: code ?? String(e) };
105
+ }
106
+ let text;
107
+ try {
108
+ text = decodeConsentBytes(bytes);
109
+ }
110
+ catch (e) {
111
+ return { state: "corrupt", problem: e.message };
112
+ }
113
+ // Empty: nothing to lose, nothing to keep aside.
114
+ if (text.trim() === "")
115
+ return { state: "answers", raw: {} };
116
+ let raw;
117
+ try {
118
+ raw = JSON.parse(text);
119
+ }
120
+ catch {
121
+ return { state: "corrupt", problem: "is not valid JSON" };
122
+ }
123
+ if (!isPlainObject(raw))
124
+ return { state: "corrupt", problem: "is not a JSON object" };
125
+ if (raw.workspaces !== undefined && !isPlainObject(raw.workspaces)) {
126
+ return { state: "corrupt", problem: 'has a "workspaces" that is not an object' };
127
+ }
128
+ if (raw.never !== undefined && !Array.isArray(raw.never)) {
129
+ return { state: "corrupt", problem: 'has a "never" that is not a list' };
130
+ }
131
+ return { state: "answers", raw };
132
+ }
133
+ /** The answers on this machine. A missing or unusable file is "none yet"; never throws. */
134
+ export function readAgentConsent() {
135
+ try {
136
+ const found = loadAgentConsentFile();
137
+ return normalize(found.state === "answers" ? found.raw : undefined);
138
+ }
139
+ catch {
140
+ return normalize(undefined);
141
+ }
142
+ }
143
+ /** The answer for `agent` under an already computed key (see consentKey). */
144
+ export function decisionForKey(consent, key, agent) {
145
+ const here = consent.workspaces[key]?.[agent];
146
+ // A "yes" given for this project outranks a "never" given elsewhere: both
147
+ // are explicit, and this one is the more specific.
148
+ if (here === "yes")
149
+ return "yes";
150
+ if (consent.never.includes(agent))
151
+ return "never";
152
+ if (here === "no")
153
+ return "no";
154
+ return undefined;
155
+ }
156
+ export function decisionFor(consent, root, agent, opts) {
157
+ return decisionForKey(consent, consentKey(root, opts), agent);
158
+ }
159
+ /** Temp file + rename, so a reader never sees half a file. */
160
+ function writeAtomic(file, content) {
161
+ fs.mkdirSync(path.dirname(file), { recursive: true });
162
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
163
+ try {
164
+ fs.writeFileSync(tmp, content, "utf-8");
165
+ fs.renameSync(tmp, file);
166
+ }
167
+ catch {
168
+ // A Windows rename refused because another process holds the target.
169
+ try {
170
+ fs.unlinkSync(tmp);
171
+ }
172
+ catch {
173
+ /* ignore */
174
+ }
175
+ fs.writeFileSync(file, content, "utf-8");
176
+ }
177
+ }
178
+ /** Rename a corrupt answers file out of the way; returns where it went. Throws when it cannot. */
179
+ function keepAside(file) {
180
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
181
+ let aside = `${file}.corrupt-${stamp}`;
182
+ for (let n = 1; fs.existsSync(aside); n++)
183
+ aside = `${file}.corrupt-${stamp}-${n}`;
184
+ fs.renameSync(file, aside);
185
+ return aside;
186
+ }
187
+ /**
188
+ * Remember an answer. "never" applies to every project on this machine and
189
+ * clears this project's own answer for the agent. Never throws: an answer
190
+ * that cannot be saved is asked again next time, which is the lesser harm.
191
+ *
192
+ * Never writes over answers it cannot read. A file that cannot be read now
193
+ * is left alone and this answer is not saved; a corrupt one is renamed to
194
+ * agent-consent.json.corrupt-<time> first, so what it held can still be
195
+ * recovered. Everything else in the file, including entries this version
196
+ * does not understand, is written back as found.
197
+ */
198
+ export function recordAgentConsent(root, agent, decision, opts) {
199
+ try {
200
+ const file = agentConsentPath();
201
+ const found = loadAgentConsentFile(file);
202
+ if (found.state === "unreadable") {
203
+ console.warn(`[vexp] ${file} could not be read (${found.problem}): the answer for ${agent} is not saved and will be asked again`);
204
+ return;
205
+ }
206
+ let raw = {};
207
+ if (found.state === "answers") {
208
+ raw = found.raw;
209
+ }
210
+ else if (found.state === "corrupt") {
211
+ let aside;
212
+ try {
213
+ aside = keepAside(file);
214
+ }
215
+ catch (e) {
216
+ console.warn(`[vexp] ${file} ${found.problem} and could not be moved aside (${e.message}): the answer for ${agent} is not saved and will be asked again`);
217
+ return;
218
+ }
219
+ console.warn(`[vexp] ${file} ${found.problem}: kept as ${path.basename(aside)} next to it, and a new file started`);
220
+ }
221
+ const key = consentKey(root, opts);
222
+ const workspaces = isPlainObject(raw.workspaces) ? { ...raw.workspaces } : {};
223
+ const current = workspaces[key];
224
+ const here = isPlainObject(current) ? { ...current } : {};
225
+ const never = Array.isArray(raw.never) ? [...raw.never] : [];
226
+ if (decision === "never") {
227
+ if (!never.includes(agent))
228
+ never.push(agent);
229
+ delete here[agent];
230
+ }
231
+ else {
232
+ here[agent] = decision;
233
+ }
234
+ if (Object.keys(here).length > 0)
235
+ workspaces[key] = here;
236
+ else
237
+ delete workspaces[key];
238
+ const out = { ...raw, version: typeof raw.version === "number" ? raw.version : 1, workspaces, never };
239
+ writeAtomic(file, JSON.stringify(out, null, 2) + "\n");
240
+ }
241
+ catch {
242
+ /* see above */
243
+ }
244
+ }