@cruxy/cli 1.11.4 → 1.11.5
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/dist/errors/constructors.js +39 -0
- package/dist/errors/types.js +6 -0
- package/dist/memory/remember-tool.js +5 -1
- package/dist/memory/service.js +27 -4
- package/dist/memory/store.js +103 -32
- package/package.json +1 -1
|
@@ -1247,6 +1247,45 @@ export function memoryInvalid(detail) {
|
|
|
1247
1247
|
],
|
|
1248
1248
|
});
|
|
1249
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* A memory write was refused because the scope's existing store could not be
|
|
1252
|
+
* loaded, so the write would have replaced everything it held with the one
|
|
1253
|
+
* new note and reported success (cli#327). The same shape as
|
|
1254
|
+
* {@link credentialsUnprotected}: nothing is written, the file is named, and
|
|
1255
|
+
* the next step depends on WHICH state the file is in —
|
|
1256
|
+
*
|
|
1257
|
+
* - `unreadable`: the bytes could not be read at all (permissions, a
|
|
1258
|
+
* directory at the path). Fix the path; the content is presumably intact.
|
|
1259
|
+
* - `invalid`: the bytes were read but are not a memory document — a
|
|
1260
|
+
* half-written file (a crash or a concurrent writer mid-write) or a hand
|
|
1261
|
+
* edit that broke the JSON or the shape. Repair or clear it.
|
|
1262
|
+
*
|
|
1263
|
+
* An ABSENT file is not this error: it is the one state where starting empty
|
|
1264
|
+
* is correct, and conflating it with these two is what caused the wipe.
|
|
1265
|
+
*/
|
|
1266
|
+
export function memoryStoreUnloadable(scope, state, path, detail, underlying) {
|
|
1267
|
+
const title = state === "unreadable"
|
|
1268
|
+
? `refusing to write memory over a store that could not be read: ${path}`
|
|
1269
|
+
: `refusing to write memory over a store that is not a valid memory file: ${path}`;
|
|
1270
|
+
const nextSteps = state === "unreadable"
|
|
1271
|
+
? [
|
|
1272
|
+
"nothing was written — the existing notes are presumably intact, but they could not be read",
|
|
1273
|
+
`make ${path} a regular file readable and writable by you, then retry`,
|
|
1274
|
+
]
|
|
1275
|
+
: [
|
|
1276
|
+
"nothing was written — saving would have replaced every note the file holds",
|
|
1277
|
+
`inspect ${path}: a half-written or hand-edited file; restore it from a backup or your editor, then retry`,
|
|
1278
|
+
`or start the scope empty with \`cruxy memory clear --scope ${scope}\` (this discards whatever the file held)`,
|
|
1279
|
+
];
|
|
1280
|
+
return new CruxyError({
|
|
1281
|
+
code: ErrorCode.MemoryStoreUnloadable,
|
|
1282
|
+
title,
|
|
1283
|
+
cause: detail,
|
|
1284
|
+
nextSteps,
|
|
1285
|
+
underlying,
|
|
1286
|
+
meta: { path, scope, state },
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1250
1289
|
// ── usage telemetry (exit 2) — C.22 ───────────────────────────────────────────
|
|
1251
1290
|
/**
|
|
1252
1291
|
* The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
|
package/dist/errors/types.js
CHANGED
|
@@ -172,6 +172,11 @@ export const ErrorCode = {
|
|
|
172
172
|
/** A memory write was refused because the content matched a secret shape —
|
|
173
173
|
* secrets are never persisted (defense in depth over C.17). */
|
|
174
174
|
MemorySecret: "CRUXY_E_MEMORY_SECRET",
|
|
175
|
+
/** A memory write was refused because the scope's existing store could not
|
|
176
|
+
* be loaded — unreadable, or readable but not a valid memory document (a
|
|
177
|
+
* torn or hand-broken file). Writing would replace everything it held with
|
|
178
|
+
* the one new note and report success (cli#327). Nothing is written. */
|
|
179
|
+
MemoryStoreUnloadable: "CRUXY_E_MEMORY_STORE_UNLOADABLE",
|
|
175
180
|
// usage telemetry (exit 2) — C.22
|
|
176
181
|
/** The local usage store is corrupt/unreadable — the read is SKIPPED and this
|
|
177
182
|
* is surfaced; never fatal to a run (usage display is best-effort). */
|
|
@@ -388,6 +393,7 @@ const EXIT_CODES = {
|
|
|
388
393
|
[ErrorCode.MemoryInvalid]: 14,
|
|
389
394
|
[ErrorCode.MemoryUntrusted]: 14,
|
|
390
395
|
[ErrorCode.MemorySecret]: 14,
|
|
396
|
+
[ErrorCode.MemoryStoreUnloadable]: 14,
|
|
391
397
|
// Usage telemetry (C.22). A corrupt store is a usage/data problem the user can
|
|
392
398
|
// fix (delete the file); it shares the usage exit code and is never fatal to a
|
|
393
399
|
// run — the aggregation just skips it.
|
|
@@ -58,7 +58,11 @@ export const rememberTool = {
|
|
|
58
58
|
// Fail loud to the model: a refused secret / invalid note is reported with
|
|
59
59
|
// its stable code so the model knows it was NOT stored (never a silent no-op).
|
|
60
60
|
if (CruxyError.is(err)) {
|
|
61
|
-
|
|
61
|
+
// The next steps ride along so the model can tell the user what to do
|
|
62
|
+
// (cli#327: "refusing to write over a store that could not be read"
|
|
63
|
+
// is only actionable with the file and the fix named).
|
|
64
|
+
const steps = err.nextSteps.length > 0 ? ` — ${err.nextSteps.join("; ")}` : "";
|
|
65
|
+
return { ok: false, error: `${err.code}: ${err.title}${steps}` };
|
|
62
66
|
}
|
|
63
67
|
return { ok: false, error: err.message };
|
|
64
68
|
}
|
package/dist/memory/service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { memoryInvalid, memorySecretRefused } from "../errors/index.js";
|
|
3
|
+
import { memoryInvalid, memorySecretRefused, memoryStoreUnloadable, } from "../errors/index.js";
|
|
4
4
|
import { buildRecallBlock } from "./recall.js";
|
|
5
5
|
import { containsSecret } from "./secrets.js";
|
|
6
6
|
import { defaultMemorySources, loadScope, saveScope, } from "./store.js";
|
|
@@ -78,7 +78,7 @@ export class MemoryService {
|
|
|
78
78
|
createdAt: this.now(),
|
|
79
79
|
};
|
|
80
80
|
const file = this.sources[scope];
|
|
81
|
-
const existing =
|
|
81
|
+
const existing = this.loadForWrite(file, scope).entries;
|
|
82
82
|
if (scope === "project") {
|
|
83
83
|
// Snapshot trust BEFORE the write — this is the launder guard.
|
|
84
84
|
const wasTrustedOrEmpty = existing.length === 0 || this.projectTrusted(existing);
|
|
@@ -113,7 +113,7 @@ export class MemoryService {
|
|
|
113
113
|
forget(id) {
|
|
114
114
|
for (const scope of ["user", "project"]) {
|
|
115
115
|
const file = this.sources[scope];
|
|
116
|
-
const entries =
|
|
116
|
+
const entries = this.loadForWrite(file, scope).entries;
|
|
117
117
|
const kept = entries.filter((e) => e.id !== id);
|
|
118
118
|
if (kept.length === entries.length)
|
|
119
119
|
continue;
|
|
@@ -131,6 +131,10 @@ export class MemoryService {
|
|
|
131
131
|
let removed = 0;
|
|
132
132
|
for (const s of scopes) {
|
|
133
133
|
const file = this.sources[s];
|
|
134
|
+
// Deliberately NOT `loadForWrite`: clearing is the documented recovery
|
|
135
|
+
// for a store that cannot be loaded (cli#327). Emptying it is the
|
|
136
|
+
// user's explicit intent here, so an unloadable file counts as 0
|
|
137
|
+
// removed and is replaced with an empty document.
|
|
134
138
|
const entries = loadScope(file, s).entries;
|
|
135
139
|
removed += entries.length;
|
|
136
140
|
saveScope(file, [], s);
|
|
@@ -150,10 +154,29 @@ export class MemoryService {
|
|
|
150
154
|
* become recallable.
|
|
151
155
|
*/
|
|
152
156
|
trustProject() {
|
|
153
|
-
|
|
157
|
+
// Trust is a fingerprint over the entry SET. Recording it for the empty
|
|
158
|
+
// set an unloadable file reads as would trust nothing and, once the file
|
|
159
|
+
// is repaired, mismatch its real content — so this refuses like a write.
|
|
160
|
+
const entries = this.loadForWrite(this.sources.project, "project").entries;
|
|
154
161
|
this.recordProjectTrust(entries);
|
|
155
162
|
return entries.length;
|
|
156
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Load a scope for a path that will WRITE the whole set back (cli#327).
|
|
166
|
+
* `absent` and `loaded` are fine; `unreadable` and `invalid` are refused
|
|
167
|
+
* with `CRUXY_E_MEMORY_STORE_UNLOADABLE`, because the "existing" set is
|
|
168
|
+
* empty only because the load failed, and saving it back would replace every
|
|
169
|
+
* note the file holds with whatever this call adds — while reporting success.
|
|
170
|
+
* A read-only path (`recall`, `status`) keeps using `loadScope` directly and
|
|
171
|
+
* surfaces the same condition as an error row instead.
|
|
172
|
+
*/
|
|
173
|
+
loadForWrite(file, scope) {
|
|
174
|
+
const load = loadScope(file, scope);
|
|
175
|
+
if (load.file.kind === "unreadable" || load.file.kind === "invalid") {
|
|
176
|
+
throw memoryStoreUnloadable(scope, load.file.kind, file, load.file.message, load.file.underlying);
|
|
177
|
+
}
|
|
178
|
+
return load;
|
|
179
|
+
}
|
|
157
180
|
recordProjectTrust(entries) {
|
|
158
181
|
this.trust.record({
|
|
159
182
|
root: this.cwd,
|
package/dist/memory/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { globalDir } from "../config/paths.js";
|
|
4
4
|
import { GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME, } from "../constants.js";
|
|
@@ -13,46 +13,60 @@ export function defaultMemorySources(cwd) {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Read and validate one scope's file.
|
|
17
|
-
* (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Read and validate one scope's file. Each entry is (1) schema-validated and
|
|
17
|
+
* (2) scanned for secret content; failures are excluded and collected. The
|
|
18
|
+
* returned `entries` all carry the requested `scope` (the on-disk `scope` field
|
|
19
|
+
* is normalized to it, so a mislabeled entry can't cross scopes).
|
|
20
|
+
*
|
|
21
|
+
* THE FILE-LEVEL STATE IS REPORTED SEPARATELY (`file`, cli#327), because three
|
|
22
|
+
* different situations used to collapse into "empty, carry on":
|
|
23
|
+
*
|
|
24
|
+
* - a MISSING file (ENOENT) is `absent` — the one state where starting from
|
|
25
|
+
* an empty set is right;
|
|
26
|
+
* - any OTHER read failure (EACCES, EISDIR, ...) is `unreadable` — something
|
|
27
|
+
* is there and we could not see it;
|
|
28
|
+
* - bytes that are not a memory document (not JSON, or the wrong shape) are
|
|
29
|
+
* `invalid` — something was there and it is damaged, or half-written.
|
|
30
|
+
*
|
|
31
|
+
* Readers (recall, `cruxy memory list`) still get `entries: []` plus an error
|
|
32
|
+
* row for the last two, so nothing that only READS changes behaviour. Writers
|
|
33
|
+
* must branch on `file.kind`: see {@link saveScope}'s contract and
|
|
34
|
+
* `MemoryService.remember`.
|
|
21
35
|
*/
|
|
22
36
|
export function loadScope(file, scope) {
|
|
23
37
|
let raw;
|
|
24
38
|
try {
|
|
25
39
|
raw = readFileSync(file, "utf8");
|
|
26
40
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err.code === "ENOENT") {
|
|
43
|
+
// Missing → nothing recalled from this scope, and a write may create it.
|
|
44
|
+
return { entries: [], errors: [], file: { kind: "absent" } };
|
|
45
|
+
}
|
|
46
|
+
// Present but unreadable. NOT absent: a writer that treated it as absent
|
|
47
|
+
// would replace whatever is there.
|
|
48
|
+
const message = `unreadable: ${err.message}`;
|
|
49
|
+
return {
|
|
50
|
+
entries: [],
|
|
51
|
+
errors: [{ scope, reason: "unreadable", id: "(file)", message }],
|
|
52
|
+
file: { kind: "unreadable", message, underlying: err },
|
|
53
|
+
};
|
|
30
54
|
}
|
|
31
55
|
let parsedFile;
|
|
32
56
|
try {
|
|
33
57
|
parsedFile = JSON.parse(raw);
|
|
34
58
|
}
|
|
35
|
-
catch {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
catch (err) {
|
|
60
|
+
// Read fine, but not JSON: a half-written file (a crash or another
|
|
61
|
+
// writer mid-`writeFileSync`) or a hand edit. Either way it HELD something.
|
|
62
|
+
const message = raw.length === 0
|
|
63
|
+
? "not valid JSON (the file is empty — a truncated or half-written store)"
|
|
64
|
+
: "not valid JSON (a half-written or hand-edited store)";
|
|
65
|
+
return invalidFile(scope, message, err);
|
|
42
66
|
}
|
|
43
67
|
const fileResult = MemoryFileSchema.safeParse(parsedFile);
|
|
44
68
|
if (!fileResult.success) {
|
|
45
|
-
return {
|
|
46
|
-
entries: [],
|
|
47
|
-
errors: [
|
|
48
|
-
{
|
|
49
|
-
scope,
|
|
50
|
-
reason: "invalid",
|
|
51
|
-
id: "(file)",
|
|
52
|
-
message: `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`,
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
};
|
|
69
|
+
return invalidFile(scope, `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`);
|
|
56
70
|
}
|
|
57
71
|
const entries = [];
|
|
58
72
|
const errors = [];
|
|
@@ -86,15 +100,72 @@ export function loadScope(file, scope) {
|
|
|
86
100
|
// move an entry across scopes).
|
|
87
101
|
entries.push({ ...parsed.data, scope });
|
|
88
102
|
});
|
|
89
|
-
return { entries, errors };
|
|
103
|
+
return { entries, errors, file: { kind: "loaded" } };
|
|
104
|
+
}
|
|
105
|
+
function invalidFile(scope, message, underlying) {
|
|
106
|
+
const file = { kind: "invalid", message, underlying };
|
|
107
|
+
return {
|
|
108
|
+
entries: [],
|
|
109
|
+
errors: [{ scope, reason: "invalid", id: "(file)", message }],
|
|
110
|
+
file,
|
|
111
|
+
};
|
|
90
112
|
}
|
|
91
113
|
/**
|
|
92
|
-
* Persist a scope's entries,
|
|
93
|
-
* needed. The user scope is written `0600` (it is personal, cross-project data)
|
|
94
|
-
*
|
|
114
|
+
* Persist a scope's entries, replacing the file. Creates the memory dir if
|
|
115
|
+
* needed. The user scope is written `0600` (it is personal, cross-project data).
|
|
116
|
+
* The project scope KEEPS THE MODE THE FILE ALREADY HAD, and takes the process
|
|
117
|
+
* umask only when creating it: a replace through rename is a new inode, and
|
|
118
|
+
* letting it land at whatever umask says would be a permission change the user
|
|
119
|
+
* never asked for, arriving through a write path — the same reason `api.env`'s
|
|
120
|
+
* mode has to survive a `sed`.
|
|
121
|
+
*
|
|
122
|
+
* WRITTEN TO A TEMP FILE AND RENAMED INTO PLACE (cli#327), the `credentials.ts`
|
|
123
|
+
* pattern. A plain `writeFileSync` on an existing path truncates first and
|
|
124
|
+
* fills in after, so anything reading in between — recall in a second cruxy,
|
|
125
|
+
* `cruxy memory list`, or another writer's load — saw an empty or half-written
|
|
126
|
+
* file. `rename(2)` swaps the whole document in one step: a reader sees the
|
|
127
|
+
* old bytes or the new ones, never a prefix. It does NOT serialize two writers
|
|
128
|
+
* (that is #304, the read-modify-write race); it only guarantees that what
|
|
129
|
+
* either of them reads is a complete document.
|
|
130
|
+
*
|
|
131
|
+
* CONTRACT FOR CALLERS: this overwrites whatever is at `file`. It must only be
|
|
132
|
+
* called with a set derived from a load whose `file.kind` was `loaded` or
|
|
133
|
+
* `absent` — never `unreadable` or `invalid`, where the caller's "existing"
|
|
134
|
+
* set is empty only because the load failed. `MemoryService` enforces that;
|
|
135
|
+
* `clear` is the deliberate exception (it is the documented recovery).
|
|
95
136
|
*/
|
|
96
137
|
export function saveScope(file, entries, scope) {
|
|
97
138
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
98
139
|
const body = JSON.stringify({ version: MEMORY_FILE_VERSION, entries }, null, 2);
|
|
99
|
-
|
|
140
|
+
// The mode the replacement must carry: explicit 0600 for the user scope;
|
|
141
|
+
// for the project scope, whatever the existing file has (absent → umask).
|
|
142
|
+
const mode = scope === "user" ? 0o600 : existingMode(file);
|
|
143
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(tmp, body, mode !== undefined ? { mode } : undefined);
|
|
146
|
+
// `writeFileSync`'s mode is masked by the umask and applies only on create;
|
|
147
|
+
// chmod pins the exact bits on the temp BEFORE it becomes the file.
|
|
148
|
+
if (mode !== undefined)
|
|
149
|
+
chmodSync(tmp, mode);
|
|
150
|
+
renameSync(tmp, file); // atomic replace; the mode moves with the inode
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
try {
|
|
154
|
+
rmSync(tmp, { force: true });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// The temp may already be gone; the target file is untouched regardless.
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** The permission bits of `file`, or `undefined` when there is nothing there
|
|
163
|
+
* to preserve (the create case takes the umask, as before). */
|
|
164
|
+
function existingMode(file) {
|
|
165
|
+
try {
|
|
166
|
+
return statSync(file).mode & 0o777;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
100
171
|
}
|