ai-dev-maintenance 0.1.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.
- package/LICENSE +21 -0
- package/README.ja.md +94 -0
- package/README.md +124 -0
- package/SECURITY.md +17 -0
- package/dist/cli.d.ts +40 -0
- package/dist/cli.js +1115 -0
- package/examples/sample-report.json +30 -0
- package/package.json +55 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
// src/doctor.ts
|
|
8
|
+
import path5 from "path";
|
|
9
|
+
|
|
10
|
+
// src/commands.ts
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
import { chmod, lstat, mkdtemp, rm } from "fs/promises";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
var ALLOWED_COMMANDS = {
|
|
16
|
+
sqlite3: "/usr/bin/sqlite3",
|
|
17
|
+
lsof: "/usr/sbin/lsof",
|
|
18
|
+
ps: "/bin/ps"
|
|
19
|
+
};
|
|
20
|
+
function isTrustedSystemCommand(stat) {
|
|
21
|
+
const allowed = new Set(Object.values(ALLOWED_COMMANDS));
|
|
22
|
+
const groupOrOtherWritable = (stat.mode & 18) !== 0;
|
|
23
|
+
return allowed.has(stat.path) && stat.uid === 0 && !stat.isSymbolicLink && !groupOrOtherWritable;
|
|
24
|
+
}
|
|
25
|
+
async function trustedCommandPath(name) {
|
|
26
|
+
const commandPath = ALLOWED_COMMANDS[name];
|
|
27
|
+
const info = await lstat(commandPath);
|
|
28
|
+
const trusted = isTrustedSystemCommand({
|
|
29
|
+
path: commandPath,
|
|
30
|
+
uid: info.uid,
|
|
31
|
+
mode: info.mode,
|
|
32
|
+
isSymbolicLink: info.isSymbolicLink()
|
|
33
|
+
});
|
|
34
|
+
if (!trusted) {
|
|
35
|
+
throw new Error(`untrusted system command: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
return commandPath;
|
|
38
|
+
}
|
|
39
|
+
async function runCommand(command, args, options = {}) {
|
|
40
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
41
|
+
const maxStdoutBytes = options.maxStdoutBytes ?? 256e3;
|
|
42
|
+
const maxStderrBytes = options.maxStderrBytes ?? 64e3;
|
|
43
|
+
const childHome = await mkdtemp(path.join(os.tmpdir(), "ai-dev-maintenance-home-"));
|
|
44
|
+
await chmod(childHome, 448);
|
|
45
|
+
return await new Promise((resolve) => {
|
|
46
|
+
const child = spawn(command, args, {
|
|
47
|
+
shell: false,
|
|
48
|
+
env: {
|
|
49
|
+
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
50
|
+
HOME: childHome
|
|
51
|
+
},
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
53
|
+
});
|
|
54
|
+
let stdout = Buffer.alloc(0);
|
|
55
|
+
let stderr = Buffer.alloc(0);
|
|
56
|
+
let stdoutTruncated = false;
|
|
57
|
+
let stderrTruncated = false;
|
|
58
|
+
let settled = false;
|
|
59
|
+
let timedOut = false;
|
|
60
|
+
let timeoutSignal;
|
|
61
|
+
let killTimer;
|
|
62
|
+
const finish = (result) => {
|
|
63
|
+
rm(childHome, { recursive: true, force: true }).catch(() => void 0).finally(
|
|
64
|
+
() => resolve({
|
|
65
|
+
...result,
|
|
66
|
+
stdoutTruncated,
|
|
67
|
+
stderrTruncated
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
const timeout = setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
timeoutSignal = "SIGTERM";
|
|
74
|
+
child.kill("SIGTERM");
|
|
75
|
+
killTimer = setTimeout(() => {
|
|
76
|
+
timeoutSignal = "SIGKILL";
|
|
77
|
+
child.kill("SIGKILL");
|
|
78
|
+
}, 1e3);
|
|
79
|
+
killTimer.unref();
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
timeout.unref();
|
|
82
|
+
child.stdout?.on("data", (chunk) => {
|
|
83
|
+
if (stdout.length >= maxStdoutBytes) {
|
|
84
|
+
stdoutTruncated = true;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const next = Buffer.concat([stdout, chunk]);
|
|
88
|
+
if (next.length > maxStdoutBytes) stdoutTruncated = true;
|
|
89
|
+
stdout = next.subarray(0, maxStdoutBytes);
|
|
90
|
+
});
|
|
91
|
+
child.stderr?.on("data", (chunk) => {
|
|
92
|
+
if (stderr.length >= maxStderrBytes) {
|
|
93
|
+
stderrTruncated = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const next = Buffer.concat([stderr, chunk]);
|
|
97
|
+
if (next.length > maxStderrBytes) stderrTruncated = true;
|
|
98
|
+
stderr = next.subarray(0, maxStderrBytes);
|
|
99
|
+
});
|
|
100
|
+
child.on("error", (error) => {
|
|
101
|
+
if (settled) return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
if (killTimer) clearTimeout(killTimer);
|
|
105
|
+
finish({ code: null, stdout: "", stderr: error.message });
|
|
106
|
+
});
|
|
107
|
+
child.on("close", (code, signal) => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
if (killTimer) clearTimeout(killTimer);
|
|
112
|
+
finish({
|
|
113
|
+
code,
|
|
114
|
+
signal: signal ?? timeoutSignal,
|
|
115
|
+
stdout: stdout.toString("utf8"),
|
|
116
|
+
stderr: stderr.toString("utf8"),
|
|
117
|
+
timedOut
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/fs-safety.ts
|
|
124
|
+
import { constants } from "fs";
|
|
125
|
+
import { access, lstat as lstat2, mkdir, realpath } from "fs/promises";
|
|
126
|
+
import path2 from "path";
|
|
127
|
+
async function collectFileIdentity(filePath, pathCategory) {
|
|
128
|
+
try {
|
|
129
|
+
const lst = await lstat2(filePath);
|
|
130
|
+
const isLink = lst.isSymbolicLink();
|
|
131
|
+
const resolved = isLink ? void 0 : await realpath(filePath).catch(() => void 0);
|
|
132
|
+
return {
|
|
133
|
+
pathCategory,
|
|
134
|
+
realpath: resolved,
|
|
135
|
+
dev: lst.dev,
|
|
136
|
+
ino: lst.ino,
|
|
137
|
+
mode: lst.mode,
|
|
138
|
+
uid: lst.uid,
|
|
139
|
+
gid: lst.gid,
|
|
140
|
+
size: lst.size,
|
|
141
|
+
mtimeMs: lst.mtimeMs,
|
|
142
|
+
nlink: lst.nlink,
|
|
143
|
+
exists: true,
|
|
144
|
+
regularFile: lst.isFile(),
|
|
145
|
+
symbolicLink: isLink
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error.code === "ENOENT") {
|
|
149
|
+
return {
|
|
150
|
+
pathCategory,
|
|
151
|
+
exists: false,
|
|
152
|
+
regularFile: false,
|
|
153
|
+
symbolicLink: false
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function detectTargetState(mainPath) {
|
|
160
|
+
const main = await collectFileIdentity(mainPath, "codex-log-db-main");
|
|
161
|
+
const wal = await collectFileIdentity(`${mainPath}-wal`, "codex-log-db-wal");
|
|
162
|
+
const shm = await collectFileIdentity(`${mainPath}-shm`, "codex-log-db-shm");
|
|
163
|
+
const blockers = [];
|
|
164
|
+
const uid = process.getuid?.();
|
|
165
|
+
if (!main.exists) blockers.push("main database is missing");
|
|
166
|
+
blockers.push(...fileBlockers(main, "main database", uid));
|
|
167
|
+
for (const sidecar of [wal, shm]) {
|
|
168
|
+
if (!sidecar.exists) continue;
|
|
169
|
+
blockers.push(...fileBlockers(sidecar, sidecar.pathCategory, uid));
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
mainPath,
|
|
173
|
+
exists: main.exists,
|
|
174
|
+
fixable: blockers.length === 0,
|
|
175
|
+
blockers,
|
|
176
|
+
main,
|
|
177
|
+
wal,
|
|
178
|
+
shm
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function fileBlockers(file, label, uid) {
|
|
182
|
+
if (!file.exists) return [];
|
|
183
|
+
const blockers = [];
|
|
184
|
+
if (file.symbolicLink) blockers.push(`${label} is a symlink`);
|
|
185
|
+
if (!file.regularFile) blockers.push(`${label} is not a regular file`);
|
|
186
|
+
if (file.nlink !== void 0 && file.nlink > 1) blockers.push(`${label} has hard links`);
|
|
187
|
+
if (uid !== void 0 && file.uid !== uid) blockers.push(`${label} is not owned by current user`);
|
|
188
|
+
if (file.mode !== void 0 && (file.mode & 18) !== 0) {
|
|
189
|
+
blockers.push(`${label} is group/other writable`);
|
|
190
|
+
}
|
|
191
|
+
return blockers;
|
|
192
|
+
}
|
|
193
|
+
async function assertDirectoryChainSafe(startDir, stopDir) {
|
|
194
|
+
const blockers = [];
|
|
195
|
+
let current = path2.resolve(startDir);
|
|
196
|
+
const stop = path2.resolve(stopDir);
|
|
197
|
+
const uid = process.getuid?.();
|
|
198
|
+
while (current === stop || current.startsWith(`${stop}${path2.sep}`)) {
|
|
199
|
+
const info = await lstat2(current);
|
|
200
|
+
if (info.isSymbolicLink()) blockers.push(`${current} is a symlink`);
|
|
201
|
+
if (uid !== void 0 && info.uid !== uid) blockers.push(`${current} is not owned by current user`);
|
|
202
|
+
if ((info.mode & 18) !== 0) blockers.push(`${current} is group/other writable`);
|
|
203
|
+
if (current === stop) break;
|
|
204
|
+
const next = path2.dirname(current);
|
|
205
|
+
if (next === current) break;
|
|
206
|
+
current = next;
|
|
207
|
+
}
|
|
208
|
+
return blockers;
|
|
209
|
+
}
|
|
210
|
+
async function assertPrivateAppDirSafe(dir) {
|
|
211
|
+
return await checkPrivateAppDirSafe(dir, { createMissing: true });
|
|
212
|
+
}
|
|
213
|
+
async function assertExistingPrivateDirSafe(dir) {
|
|
214
|
+
return await checkPrivateAppDirSafe(dir, { createMissing: false });
|
|
215
|
+
}
|
|
216
|
+
async function checkPrivateAppDirSafe(dir, options) {
|
|
217
|
+
const blockers = [];
|
|
218
|
+
const resolved = path2.resolve(dir);
|
|
219
|
+
const uid = process.getuid?.();
|
|
220
|
+
const root = path2.parse(resolved).root;
|
|
221
|
+
const parts = resolved.slice(root.length).split(path2.sep).filter(Boolean);
|
|
222
|
+
const markerIndex = parts.indexOf(".ai-dev-maintenance");
|
|
223
|
+
const startIndex = markerIndex >= 0 ? markerIndex : parts.length - 1;
|
|
224
|
+
let current = path2.join(root, ...parts.slice(0, startIndex));
|
|
225
|
+
for (const [offset, part] of parts.slice(startIndex).entries()) {
|
|
226
|
+
current = path2.join(current, part);
|
|
227
|
+
let info = await lstat2(current).catch(async (error) => {
|
|
228
|
+
if (error.code !== "ENOENT") throw error;
|
|
229
|
+
if (!options.createMissing) {
|
|
230
|
+
blockers.push(`${offset === 0 ? "<app-data>" : `<app-data-component:${offset}>`} is missing`);
|
|
231
|
+
return void 0;
|
|
232
|
+
}
|
|
233
|
+
await mkdir(current, { mode: 448 });
|
|
234
|
+
return await lstat2(current);
|
|
235
|
+
});
|
|
236
|
+
const label = offset === 0 ? "<app-data>" : `<app-data-component:${offset}>`;
|
|
237
|
+
if (!info) return blockers;
|
|
238
|
+
if (info.isSymbolicLink()) {
|
|
239
|
+
blockers.push(`${label} is a symlink`);
|
|
240
|
+
return blockers;
|
|
241
|
+
}
|
|
242
|
+
if (!info.isDirectory()) {
|
|
243
|
+
blockers.push(`${label} is not a directory`);
|
|
244
|
+
return blockers;
|
|
245
|
+
}
|
|
246
|
+
if (uid !== void 0 && info.uid !== uid) blockers.push(`${label} is not owned by current user`);
|
|
247
|
+
if ((info.mode & 18) !== 0) blockers.push(`${label} is group/other writable`);
|
|
248
|
+
else if ((info.mode & 63) !== 0) blockers.push(`${label} exposes group/other permissions`);
|
|
249
|
+
if (blockers.length > 0) return blockers;
|
|
250
|
+
}
|
|
251
|
+
return blockers;
|
|
252
|
+
}
|
|
253
|
+
async function assertSafeReadablePrivateFile(filePath, label) {
|
|
254
|
+
const blockers = [];
|
|
255
|
+
const uid = process.getuid?.();
|
|
256
|
+
let info;
|
|
257
|
+
try {
|
|
258
|
+
info = await lstat2(filePath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error.code === "ENOENT") return [`${label} is missing`];
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (info.isSymbolicLink()) blockers.push(`${label} is a symlink`);
|
|
264
|
+
if (!info.isFile()) blockers.push(`${label} is not a regular file`);
|
|
265
|
+
if (info.nlink > 1) blockers.push(`${label} has hard links`);
|
|
266
|
+
if (uid !== void 0 && info.uid !== uid) blockers.push(`${label} is not owned by current user`);
|
|
267
|
+
if ((info.mode & 18) !== 0) blockers.push(`${label} is group/other writable`);
|
|
268
|
+
return blockers;
|
|
269
|
+
}
|
|
270
|
+
function compareTargetIdentities(before, after, options) {
|
|
271
|
+
const blockers = [];
|
|
272
|
+
compareFileIdentity("main database", before.main, after.main, Boolean(options.allowMainSizeMtimeChange), blockers);
|
|
273
|
+
compareFileIdentity("codex-log-db-wal", before.wal, after.wal, options.allowSidecarSizeMtimeChange, blockers);
|
|
274
|
+
compareFileIdentity("codex-log-db-shm", before.shm, after.shm, options.allowSidecarSizeMtimeChange, blockers);
|
|
275
|
+
return blockers;
|
|
276
|
+
}
|
|
277
|
+
function compareFileIdentity(label, before, after, allowSizeMtimeChange, blockers) {
|
|
278
|
+
if (!before?.exists && !after?.exists) return;
|
|
279
|
+
const stableKeys = [
|
|
280
|
+
"exists",
|
|
281
|
+
"regularFile",
|
|
282
|
+
"symbolicLink",
|
|
283
|
+
"dev",
|
|
284
|
+
"ino",
|
|
285
|
+
"nlink",
|
|
286
|
+
"mode",
|
|
287
|
+
"uid",
|
|
288
|
+
"gid"
|
|
289
|
+
];
|
|
290
|
+
for (const key of stableKeys) {
|
|
291
|
+
if (before?.[key] !== after?.[key]) {
|
|
292
|
+
blockers.push(`${label} identity changed`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!allowSizeMtimeChange && (before?.size !== after?.size || before?.mtimeMs !== after?.mtimeMs)) {
|
|
297
|
+
blockers.push(`${label} identity changed`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function safeTargetStateForReport(state) {
|
|
301
|
+
return {
|
|
302
|
+
exists: state.exists,
|
|
303
|
+
fixable: state.fixable,
|
|
304
|
+
blockers: state.blockers,
|
|
305
|
+
main: safeIdentityForReport(state.main),
|
|
306
|
+
wal: safeIdentityForReport(state.wal),
|
|
307
|
+
shm: safeIdentityForReport(state.shm)
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function safeIdentityForReport(file) {
|
|
311
|
+
if (!file) return void 0;
|
|
312
|
+
return {
|
|
313
|
+
pathCategory: file.pathCategory,
|
|
314
|
+
exists: file.exists,
|
|
315
|
+
regularFile: file.regularFile,
|
|
316
|
+
symbolicLink: file.symbolicLink,
|
|
317
|
+
size: file.size
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/paths.ts
|
|
322
|
+
import { pathToFileURL } from "url";
|
|
323
|
+
import os2 from "os";
|
|
324
|
+
import path3 from "path";
|
|
325
|
+
var macHomePathPattern = /\/Users\/[^/]+/g;
|
|
326
|
+
var nonHomeAbsolutePathStart = /\/(?:private|Volumes|tmp|var)\//g;
|
|
327
|
+
function createSqliteUri(absolutePath, mode) {
|
|
328
|
+
if (!path3.isAbsolute(absolutePath)) {
|
|
329
|
+
throw new Error("SQLite paths must be absolute");
|
|
330
|
+
}
|
|
331
|
+
const url = pathToFileURL(absolutePath);
|
|
332
|
+
url.searchParams.set("mode", mode);
|
|
333
|
+
return url.href;
|
|
334
|
+
}
|
|
335
|
+
function redactPath(value) {
|
|
336
|
+
const home = os2.homedir();
|
|
337
|
+
let redacted = value;
|
|
338
|
+
if (home) {
|
|
339
|
+
redacted = redacted.replaceAll(home, "<home>");
|
|
340
|
+
}
|
|
341
|
+
redacted = redacted.replace(macHomePathPattern, "<home>");
|
|
342
|
+
return redactNonHomeAbsolutePaths(redacted);
|
|
343
|
+
}
|
|
344
|
+
function redactNonHomeAbsolutePaths(value) {
|
|
345
|
+
let result = "";
|
|
346
|
+
let cursor = 0;
|
|
347
|
+
for (const match of value.matchAll(nonHomeAbsolutePathStart)) {
|
|
348
|
+
const start = match.index ?? 0;
|
|
349
|
+
if (start < cursor) continue;
|
|
350
|
+
const end = findAbsolutePathEnd(value, start);
|
|
351
|
+
result += value.slice(cursor, start);
|
|
352
|
+
result += "<absolute-path>";
|
|
353
|
+
cursor = end;
|
|
354
|
+
}
|
|
355
|
+
return result + value.slice(cursor);
|
|
356
|
+
}
|
|
357
|
+
function findAbsolutePathEnd(value, start) {
|
|
358
|
+
let end = start;
|
|
359
|
+
while (end < value.length && !['"', "'", ",", ";", ":", ")", "\n", "\r"].includes(value[end] ?? "")) {
|
|
360
|
+
end++;
|
|
361
|
+
}
|
|
362
|
+
const segment = value.slice(start, end);
|
|
363
|
+
const extensionBeforeSpace = /\.[A-Za-z0-9_-]+(?=\s)/.exec(segment);
|
|
364
|
+
if (extensionBeforeSpace) return start + extensionBeforeSpace.index + extensionBeforeSpace[0].length;
|
|
365
|
+
return end;
|
|
366
|
+
}
|
|
367
|
+
function defaultCodexHome(env = process.env) {
|
|
368
|
+
const defaultHome = path3.join(os2.homedir(), ".codex");
|
|
369
|
+
const candidate = env.CODEX_HOME;
|
|
370
|
+
if (candidate && path3.resolve(candidate) !== defaultHome) {
|
|
371
|
+
return { codexHome: path3.resolve(candidate), custom: true };
|
|
372
|
+
}
|
|
373
|
+
return { codexHome: defaultHome, custom: false };
|
|
374
|
+
}
|
|
375
|
+
function targetTriple(mainPath) {
|
|
376
|
+
return [mainPath, `${mainPath}-wal`, `${mainPath}-shm`];
|
|
377
|
+
}
|
|
378
|
+
function appDataHome() {
|
|
379
|
+
return path3.join(os2.homedir(), ".ai-dev-maintenance");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/reports.ts
|
|
383
|
+
import { readFile, readdir, writeFile } from "fs/promises";
|
|
384
|
+
import path4 from "path";
|
|
385
|
+
async function ensurePrivateDir(dir) {
|
|
386
|
+
const blockers = await assertPrivateAppDirSafe(dir);
|
|
387
|
+
if (blockers.length > 0) {
|
|
388
|
+
throw new Error(`unsafe private directory: ${blockers.join("; ")}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function writeReport(report) {
|
|
392
|
+
const dir = path4.join(appDataHome(), "reports");
|
|
393
|
+
await ensurePrivateDir(dir);
|
|
394
|
+
const stamp = report.generatedAt.replace(/[:.]/g, "-");
|
|
395
|
+
const file = path4.join(dir, `report-${stamp}.json`);
|
|
396
|
+
await writeFile(file, `${JSON.stringify(report, null, 2)}
|
|
397
|
+
`, { mode: 384, flag: "wx" });
|
|
398
|
+
return file;
|
|
399
|
+
}
|
|
400
|
+
async function latestReport() {
|
|
401
|
+
const dir = path4.join(appDataHome(), "reports");
|
|
402
|
+
await ensurePrivateDir(dir);
|
|
403
|
+
let entries;
|
|
404
|
+
try {
|
|
405
|
+
entries = await readdir(dir);
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const reports = entries.filter((entry) => /^report-.*\.json$/.test(entry)).sort();
|
|
410
|
+
const latest = reports.at(-1);
|
|
411
|
+
if (!latest) return null;
|
|
412
|
+
const file = path4.join(dir, latest);
|
|
413
|
+
const fileBlockers2 = await assertSafeReadablePrivateFile(file, "report file");
|
|
414
|
+
if (fileBlockers2.length > 0) {
|
|
415
|
+
throw new Error(`unsafe report file: ${fileBlockers2.join("; ")}`);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
path: file,
|
|
419
|
+
report: sanitizeReportForOutput(JSON.parse(await readFile(file, "utf8")))
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function sanitizeReportForOutput(report) {
|
|
423
|
+
return {
|
|
424
|
+
schemaVersion: 1,
|
|
425
|
+
toolVersion: sanitizeString(report.toolVersion),
|
|
426
|
+
generatedAt: sanitizeString(report.generatedAt),
|
|
427
|
+
command: sanitizeString(report.command),
|
|
428
|
+
status: report.status,
|
|
429
|
+
redacted: true,
|
|
430
|
+
target: {
|
|
431
|
+
kind: report.target?.kind === "default-codex-log-db" ? "default-codex-log-db" : "unknown",
|
|
432
|
+
pathCategory: sanitizeString(report.target?.pathCategory ?? "unknown")
|
|
433
|
+
},
|
|
434
|
+
findings: sanitizeRecord(report.findings),
|
|
435
|
+
metrics: sanitizeRecord(report.metrics),
|
|
436
|
+
blockedReasons: Array.isArray(report.blockedReasons) ? report.blockedReasons.map((reason) => sanitizeString(String(reason))) : [],
|
|
437
|
+
nextSafeAction: report.nextSafeAction ? sanitizeString(report.nextSafeAction) : void 0
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function sanitizeRecord(value) {
|
|
441
|
+
const sanitized = sanitizeJsonValue(value);
|
|
442
|
+
return isPlainObject(sanitized) ? sanitized : {};
|
|
443
|
+
}
|
|
444
|
+
function sanitizeJsonValue(value, key = "") {
|
|
445
|
+
if (isForbiddenReportKey(key)) return void 0;
|
|
446
|
+
if (typeof value === "string") return sanitizeString(value);
|
|
447
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
|
|
448
|
+
if (Array.isArray(value)) {
|
|
449
|
+
return value.map((item) => sanitizeJsonValue(item)).filter((item) => item !== void 0);
|
|
450
|
+
}
|
|
451
|
+
if (!isPlainObject(value)) return void 0;
|
|
452
|
+
const result = {};
|
|
453
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
454
|
+
const sanitized = sanitizeJsonValue(childValue, childKey);
|
|
455
|
+
if (sanitized !== void 0) result[sanitizeReportKey(childKey)] = sanitized;
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
function sanitizeString(value) {
|
|
460
|
+
return redactPath(value);
|
|
461
|
+
}
|
|
462
|
+
function sanitizeReportKey(key) {
|
|
463
|
+
return redactPath(key);
|
|
464
|
+
}
|
|
465
|
+
function isForbiddenReportKey(key) {
|
|
466
|
+
return (/* @__PURE__ */ new Set([
|
|
467
|
+
"dev",
|
|
468
|
+
"ino",
|
|
469
|
+
"uid",
|
|
470
|
+
"gid",
|
|
471
|
+
"mode",
|
|
472
|
+
"mtimeMs",
|
|
473
|
+
"realpath",
|
|
474
|
+
"rawStderr",
|
|
475
|
+
"stderr",
|
|
476
|
+
"stdout"
|
|
477
|
+
])).has(key);
|
|
478
|
+
}
|
|
479
|
+
function isPlainObject(value) {
|
|
480
|
+
return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/safety.ts
|
|
484
|
+
function classifyLsofResult(result) {
|
|
485
|
+
if ("timedOut" in result && result.timedOut) {
|
|
486
|
+
return { usable: false, openHandles: false, reason: "timeout" };
|
|
487
|
+
}
|
|
488
|
+
if ("stdoutTruncated" in result && result.stdoutTruncated) {
|
|
489
|
+
return { usable: false, openHandles: false, reason: "lsof_output_truncated" };
|
|
490
|
+
}
|
|
491
|
+
if ("stderrTruncated" in result && result.stderrTruncated) {
|
|
492
|
+
return { usable: false, openHandles: false, reason: "lsof_error_truncated" };
|
|
493
|
+
}
|
|
494
|
+
const stdout = result.stdout.trim();
|
|
495
|
+
const stderr = result.stderr.trim();
|
|
496
|
+
if (stderr.length > 0) {
|
|
497
|
+
const lower = stderr.toLowerCase();
|
|
498
|
+
if (lower.includes("permission denied") || lower.includes("operation not permitted")) {
|
|
499
|
+
return { usable: false, openHandles: false, reason: "permission_denied" };
|
|
500
|
+
}
|
|
501
|
+
return { usable: false, openHandles: false, reason: "nonzero_stderr" };
|
|
502
|
+
}
|
|
503
|
+
if (result.code === 0) {
|
|
504
|
+
return {
|
|
505
|
+
usable: true,
|
|
506
|
+
openHandles: stdout.length > 0,
|
|
507
|
+
reason: stdout.length > 0 ? "open handles reported" : "no open handles reported"
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (result.code === 1 && stdout.length === 0 && stderr.length === 0) {
|
|
511
|
+
return { usable: true, openHandles: false, reason: "no open handles reported" };
|
|
512
|
+
}
|
|
513
|
+
return { usable: false, openHandles: false, reason: "nonzero_exit" };
|
|
514
|
+
}
|
|
515
|
+
function planFixSafety(input) {
|
|
516
|
+
const reasons = [];
|
|
517
|
+
if (input.knownCodexProcessExists) reasons.push("known Codex process is running");
|
|
518
|
+
if (input.anyOpenHandleOnTarget) reasons.push("target database is open by a process");
|
|
519
|
+
if (!input.lsofUsable) reasons.push("open-handle check is unavailable");
|
|
520
|
+
if (input.processListTruncated) reasons.push("process list check was truncated");
|
|
521
|
+
return { allowed: reasons.length === 0, reasons };
|
|
522
|
+
}
|
|
523
|
+
function parseKnownCodexProcess(psOutput, currentPid = process.pid) {
|
|
524
|
+
return psOutput.split("\n").map((line) => line.trim()).filter(Boolean).some((line) => {
|
|
525
|
+
const pid = Number(line.split(/\s+/, 1)[0]);
|
|
526
|
+
if (pid === currentPid) return false;
|
|
527
|
+
return /\b(Codex|codex|codex-cli|Codex Helper|OpenAI Codex|com\.openai\.codex)\b/.test(line);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/sqlite.ts
|
|
532
|
+
async function checkSqliteJsonSupport() {
|
|
533
|
+
try {
|
|
534
|
+
const sqlite = await trustedCommandPath("sqlite3");
|
|
535
|
+
const result = await runCommand(sqlite, ["-json", "-init", "/dev/null", ":memory:", "select 1 as ok"], {
|
|
536
|
+
timeoutMs: 5e3
|
|
537
|
+
});
|
|
538
|
+
if (result.code !== 0) return { ok: false, reason: result.stderr.trim() || "sqlite3 failed" };
|
|
539
|
+
const parsed = JSON.parse(result.stdout);
|
|
540
|
+
return { ok: parsed?.[0]?.ok === 1, reason: parsed?.[0]?.ok === 1 ? void 0 : "unexpected sqlite3 json output" };
|
|
541
|
+
} catch (error) {
|
|
542
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async function inspectSqliteSnapshot(mainPath) {
|
|
546
|
+
try {
|
|
547
|
+
const quickCheck = await sqliteJson(mainPath, "ro", "PRAGMA quick_check;");
|
|
548
|
+
const table = await sqliteJson(
|
|
549
|
+
mainPath,
|
|
550
|
+
"ro",
|
|
551
|
+
"SELECT name FROM sqlite_schema WHERE type='table' AND name='logs';"
|
|
552
|
+
);
|
|
553
|
+
const columns = await sqliteJson(mainPath, "ro", "PRAGMA table_info(logs);");
|
|
554
|
+
const pageSize = await sqliteJson(mainPath, "ro", "PRAGMA page_size;");
|
|
555
|
+
const pageCount = await sqliteJson(mainPath, "ro", "PRAGMA page_count;");
|
|
556
|
+
const freelistCount = await sqliteJson(mainPath, "ro", "PRAGMA freelist_count;");
|
|
557
|
+
const autoVacuum = await sqliteJson(mainPath, "ro", "PRAGMA auto_vacuum;");
|
|
558
|
+
const columnNames = Array.isArray(columns) ? columns.map((row) => String(row.name)) : [];
|
|
559
|
+
return {
|
|
560
|
+
quickCheck: String(quickCheck?.[0]?.quick_check ?? ""),
|
|
561
|
+
hasLogsTable: Boolean(table?.[0]?.name === "logs"),
|
|
562
|
+
columns: columnNames,
|
|
563
|
+
recognizedSchema: quickCheck?.[0]?.quick_check === "ok" && table?.[0]?.name === "logs" && columnNames.includes("id") && columnNames.includes("level"),
|
|
564
|
+
pageSize: Number(pageSize?.[0]?.page_size ?? 0),
|
|
565
|
+
pageCount: Number(pageCount?.[0]?.page_count ?? 0),
|
|
566
|
+
freelistCount: Number(freelistCount?.[0]?.freelist_count ?? 0),
|
|
567
|
+
autoVacuum: Number(autoVacuum?.[0]?.auto_vacuum ?? 0)
|
|
568
|
+
};
|
|
569
|
+
} catch (error) {
|
|
570
|
+
return {
|
|
571
|
+
recognizedSchema: false,
|
|
572
|
+
error: sanitizeSqliteError(error)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function sqliteJson(dbPath, mode, sql) {
|
|
577
|
+
const sqlite = await trustedCommandPath("sqlite3");
|
|
578
|
+
const result = await runCommand(sqlite, ["-json", "-init", "/dev/null", createSqliteUri(dbPath, mode), sql], {
|
|
579
|
+
timeoutMs: 15e3
|
|
580
|
+
});
|
|
581
|
+
if (result.code !== 0) {
|
|
582
|
+
throw new Error(classifySqliteCommandFailure(result.stderr, result.code));
|
|
583
|
+
}
|
|
584
|
+
return JSON.parse(result.stdout || "[]");
|
|
585
|
+
}
|
|
586
|
+
function classifySqliteCommandFailure(stderr, code) {
|
|
587
|
+
const lower = stderr.toLowerCase();
|
|
588
|
+
if (lower.includes("permission denied") || lower.includes("operation not permitted")) {
|
|
589
|
+
return "sqlite3 permission_denied";
|
|
590
|
+
}
|
|
591
|
+
if (lower.includes("database is locked") || lower.includes("busy")) {
|
|
592
|
+
return "sqlite3 database_busy";
|
|
593
|
+
}
|
|
594
|
+
if (lower.includes("file is not a database") || lower.includes("malformed")) {
|
|
595
|
+
return "sqlite3 invalid_database";
|
|
596
|
+
}
|
|
597
|
+
return code === null ? "sqlite3 failed" : `sqlite3 exited with ${code}`;
|
|
598
|
+
}
|
|
599
|
+
function sanitizeSqliteError(error) {
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
601
|
+
return redactPath(message);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/version.ts
|
|
605
|
+
var TOOL_VERSION = "0.1.0";
|
|
606
|
+
var REPORT_SCHEMA_VERSION = 1;
|
|
607
|
+
|
|
608
|
+
// src/doctor.ts
|
|
609
|
+
async function runDoctor(options = {}) {
|
|
610
|
+
const platform = options.platform ?? process.platform;
|
|
611
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
612
|
+
if (platform !== "darwin") {
|
|
613
|
+
const report2 = baseReport("doctor", generatedAt, "unsupported");
|
|
614
|
+
report2.blockedReasons.push("platform is unsupported");
|
|
615
|
+
report2.nextSafeAction = "Run this tool on macOS.";
|
|
616
|
+
return { report: report2 };
|
|
617
|
+
}
|
|
618
|
+
const { codexHome, custom } = defaultCodexHome(options.env);
|
|
619
|
+
const mainPath = path5.join(codexHome, "logs_2.sqlite");
|
|
620
|
+
const state = await detectTargetState(mainPath);
|
|
621
|
+
const report = baseReport("doctor", generatedAt, state.fixable ? "ok" : "partial");
|
|
622
|
+
report.target.pathCategory = custom ? "custom-codex-home" : "<home>/.codex/logs_2.sqlite";
|
|
623
|
+
report.findings.targetState = redactState(state);
|
|
624
|
+
report.blockedReasons.push(...state.blockers);
|
|
625
|
+
if (custom) report.blockedReasons.push("custom CODEX_HOME is read-only in doctor and rejected by fix");
|
|
626
|
+
const sqliteSupport = await checkSqliteJsonSupport();
|
|
627
|
+
report.findings.sqliteJson = sqliteSupport;
|
|
628
|
+
const lsof = await checkOpenHandles(targetTriple(mainPath));
|
|
629
|
+
report.findings.openHandles = lsof;
|
|
630
|
+
const knownProcess = await knownCodexProcessExists();
|
|
631
|
+
report.findings.knownCodexProcessExists = knownProcess;
|
|
632
|
+
report.findings.sqlite = {
|
|
633
|
+
available: false,
|
|
634
|
+
reason: "source database inspection is skipped in v1 to avoid copying private log bytes"
|
|
635
|
+
};
|
|
636
|
+
const reportPath = await writeReport(report);
|
|
637
|
+
if (options.showPaths) report.findings.reportPath = redactPath(reportPath);
|
|
638
|
+
return { report, reportPath };
|
|
639
|
+
}
|
|
640
|
+
async function checkOpenHandles(paths) {
|
|
641
|
+
try {
|
|
642
|
+
const lsof = await trustedCommandPath("lsof");
|
|
643
|
+
const result = await runCommand(lsof, ["-F", "pcn", ...paths], { timeoutMs: 5e3 });
|
|
644
|
+
return classifyLsofResult(result);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
return {
|
|
647
|
+
usable: false,
|
|
648
|
+
openHandles: false,
|
|
649
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function knownCodexProcessExists() {
|
|
654
|
+
try {
|
|
655
|
+
const ps = await trustedCommandPath("ps");
|
|
656
|
+
const result = await runCommand(ps, ["-axo", "pid=,comm=,command="], { timeoutMs: 5e3 });
|
|
657
|
+
if (result.stdoutTruncated || result.stderrTruncated) return "unknown";
|
|
658
|
+
if (result.code !== 0) return "unknown";
|
|
659
|
+
return parseKnownCodexProcess(result.stdout);
|
|
660
|
+
} catch {
|
|
661
|
+
return "unknown";
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function baseReport(command, generatedAt, status) {
|
|
665
|
+
return {
|
|
666
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
667
|
+
toolVersion: TOOL_VERSION,
|
|
668
|
+
generatedAt,
|
|
669
|
+
command,
|
|
670
|
+
status,
|
|
671
|
+
redacted: true,
|
|
672
|
+
target: {
|
|
673
|
+
kind: "default-codex-log-db",
|
|
674
|
+
pathCategory: "<home>/.codex/logs_2.sqlite"
|
|
675
|
+
},
|
|
676
|
+
findings: {},
|
|
677
|
+
metrics: {},
|
|
678
|
+
blockedReasons: []
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function redactState(state) {
|
|
682
|
+
return safeTargetStateForReport(state);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/fix.ts
|
|
686
|
+
import { chmod as chmod2, mkdtemp as mkdtemp2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
687
|
+
import path6 from "path";
|
|
688
|
+
async function runFixSafe(options = {}) {
|
|
689
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
690
|
+
const report = baseFixReport(generatedAt);
|
|
691
|
+
if ((options.platform ?? process.platform) !== "darwin") {
|
|
692
|
+
report.status = "unsupported";
|
|
693
|
+
report.blockedReasons.push("platform is unsupported");
|
|
694
|
+
return { report };
|
|
695
|
+
}
|
|
696
|
+
const { codexHome, custom } = defaultCodexHome(options.env);
|
|
697
|
+
if (custom) {
|
|
698
|
+
report.status = "blocked";
|
|
699
|
+
report.target.pathCategory = "custom-codex-home";
|
|
700
|
+
report.blockedReasons.push("custom CODEX_HOME is rejected by fix");
|
|
701
|
+
report.nextSafeAction = "Run doctor for diagnostics only, or unset CODEX_HOME before fix --safe.";
|
|
702
|
+
return { report, reportPath: await writeReport(report) };
|
|
703
|
+
}
|
|
704
|
+
const mainPath = path6.join(codexHome, "logs_2.sqlite");
|
|
705
|
+
report.blockedReasons.push(...await targetDirectoryBlockers(mainPath));
|
|
706
|
+
const preflightResult = await runPreflight(mainPath);
|
|
707
|
+
report.findings.preflight = redactPreflightFindings(preflightResult.findings);
|
|
708
|
+
report.blockedReasons.push(...preflightResult.blockers);
|
|
709
|
+
if (report.blockedReasons.length > 0) {
|
|
710
|
+
report.status = "blocked";
|
|
711
|
+
report.nextSafeAction = "Close AI coding tools, verify the target path, then run doctor again.";
|
|
712
|
+
return { report, reportPath: await writeReport(report) };
|
|
713
|
+
}
|
|
714
|
+
const beforeBackup = await runPreflight(mainPath);
|
|
715
|
+
report.blockedReasons.push(...await targetDirectoryBlockers(mainPath));
|
|
716
|
+
report.blockedReasons.push(
|
|
717
|
+
...compareTargetIdentities(preflightResult.findings.targetState, beforeBackup.findings.targetState, {
|
|
718
|
+
allowSidecarSizeMtimeChange: false
|
|
719
|
+
})
|
|
720
|
+
);
|
|
721
|
+
if (report.blockedReasons.length > 0) {
|
|
722
|
+
report.status = "blocked";
|
|
723
|
+
return { report, reportPath: await writeReport(report) };
|
|
724
|
+
}
|
|
725
|
+
if (beforeBackup.blockers.length > 0) {
|
|
726
|
+
report.status = "blocked";
|
|
727
|
+
report.blockedReasons.push(...beforeBackup.blockers.map((reason) => `before backup: ${reason}`));
|
|
728
|
+
return { report, reportPath: await writeReport(report) };
|
|
729
|
+
}
|
|
730
|
+
const backup = await createBackup(mainPath);
|
|
731
|
+
report.metrics.backupCreated = true;
|
|
732
|
+
report.findings.backup = { path: redactPath(backup.path), manifest: redactPath(backup.manifestPath) };
|
|
733
|
+
const beforeMutation = await runPreflight(mainPath);
|
|
734
|
+
report.blockedReasons.push(...await targetDirectoryBlockers(mainPath));
|
|
735
|
+
report.blockedReasons.push(
|
|
736
|
+
...compareTargetIdentities(preflightResult.findings.targetState, beforeMutation.findings.targetState, {
|
|
737
|
+
allowSidecarSizeMtimeChange: false
|
|
738
|
+
})
|
|
739
|
+
);
|
|
740
|
+
if (report.blockedReasons.length > 0) {
|
|
741
|
+
report.status = "blocked";
|
|
742
|
+
return { report, reportPath: await writeReport(report) };
|
|
743
|
+
}
|
|
744
|
+
if (beforeMutation.blockers.length > 0) {
|
|
745
|
+
report.status = "blocked";
|
|
746
|
+
report.blockedReasons.push(...beforeMutation.blockers.map((reason) => `before mutation: ${reason}`));
|
|
747
|
+
return { report, reportPath: await writeReport(report) };
|
|
748
|
+
}
|
|
749
|
+
const beforeWalBytes = preflightResult.findings.targetState?.wal?.size ?? 0;
|
|
750
|
+
const sqlite = await trustedCommandPath("sqlite3");
|
|
751
|
+
const dbUri = createSqliteUri(mainPath, "rw");
|
|
752
|
+
try {
|
|
753
|
+
report.metrics.checkpointAttempted = true;
|
|
754
|
+
await runCheckpoint(sqlite, dbUri);
|
|
755
|
+
await runCheckpoint(sqlite, dbUri);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
report.status = "blocked";
|
|
758
|
+
report.blockedReasons.push(error instanceof Error ? error.message : String(error));
|
|
759
|
+
return { report, reportPath: await writeReport(report) };
|
|
760
|
+
}
|
|
761
|
+
const postMutation = await runPreflight(mainPath);
|
|
762
|
+
report.blockedReasons.push(
|
|
763
|
+
...compareTargetIdentities(beforeMutation.findings.targetState, postMutation.findings.targetState, {
|
|
764
|
+
allowMainSizeMtimeChange: true,
|
|
765
|
+
allowSidecarSizeMtimeChange: true
|
|
766
|
+
})
|
|
767
|
+
);
|
|
768
|
+
if (report.blockedReasons.length > 0) {
|
|
769
|
+
report.status = "blocked";
|
|
770
|
+
return { report, reportPath: await writeReport(report) };
|
|
771
|
+
}
|
|
772
|
+
if (postMutation.blockers.length > 0) {
|
|
773
|
+
report.status = "blocked";
|
|
774
|
+
report.blockedReasons.push(...postMutation.blockers.map((reason) => `after mutation: ${reason}`));
|
|
775
|
+
return { report, reportPath: await writeReport(report) };
|
|
776
|
+
}
|
|
777
|
+
const postflight = await runPreflight(mainPath);
|
|
778
|
+
if (postflight.blockers.length > 0) {
|
|
779
|
+
report.status = "blocked";
|
|
780
|
+
report.blockedReasons.push(...postflight.blockers);
|
|
781
|
+
} else {
|
|
782
|
+
report.status = "ok";
|
|
783
|
+
}
|
|
784
|
+
const afterWalBytes = postflight.findings.targetState?.wal?.size ?? 0;
|
|
785
|
+
if (afterWalBytes > 0) {
|
|
786
|
+
report.status = "blocked";
|
|
787
|
+
report.blockedReasons.push("WAL was not truncated");
|
|
788
|
+
}
|
|
789
|
+
report.metrics.beforeWalBytes = beforeWalBytes;
|
|
790
|
+
report.metrics.afterWalBytes = afterWalBytes;
|
|
791
|
+
report.metrics.reclaimedBytes = Math.max(0, Number(beforeWalBytes) - Number(afterWalBytes));
|
|
792
|
+
report.metrics.mainDbForcedShrink = false;
|
|
793
|
+
report.nextSafeAction = "Review the before/after metrics in the saved report.";
|
|
794
|
+
return { report, reportPath: await writeReport(report) };
|
|
795
|
+
}
|
|
796
|
+
async function runPreflight(mainPath) {
|
|
797
|
+
const blockers = [];
|
|
798
|
+
const targetState = await detectTargetState(mainPath);
|
|
799
|
+
blockers.push(...targetState.blockers);
|
|
800
|
+
const sqliteSupport = await checkSqliteJsonSupport();
|
|
801
|
+
if (!sqliteSupport.ok) blockers.push("sqlite3 JSON mode is unavailable");
|
|
802
|
+
const lsof = await checkOpenHandles(targetTriple(mainPath));
|
|
803
|
+
const knownProcess = await knownCodexProcessExists();
|
|
804
|
+
const safety = planFixSafety({
|
|
805
|
+
knownCodexProcessExists: knownProcess === true || knownProcess === "unknown",
|
|
806
|
+
anyOpenHandleOnTarget: lsof.openHandles,
|
|
807
|
+
lsofUsable: lsof.usable
|
|
808
|
+
});
|
|
809
|
+
blockers.push(...safety.reasons);
|
|
810
|
+
return {
|
|
811
|
+
blockers,
|
|
812
|
+
findings: {
|
|
813
|
+
targetState,
|
|
814
|
+
sqliteJson: sqliteSupport,
|
|
815
|
+
openHandles: lsof,
|
|
816
|
+
knownCodexProcessExists: knownProcess,
|
|
817
|
+
sqlite: {
|
|
818
|
+
available: false,
|
|
819
|
+
reason: "source database inspection is skipped in v1 to avoid copying private log bytes"
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function redactPreflightFindings(findings) {
|
|
825
|
+
return {
|
|
826
|
+
...findings,
|
|
827
|
+
targetState: safeTargetStateForReport(findings.targetState)
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
async function createBackup(mainPath) {
|
|
831
|
+
const backupDir = path6.join(appDataHome(), "backups");
|
|
832
|
+
await ensurePrivateDir(backupDir);
|
|
833
|
+
const workDir = await mkdtemp2(path6.join(backupDir, "backup-"));
|
|
834
|
+
await chmod2(workDir, 448);
|
|
835
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
836
|
+
const backupPath = path6.join(workDir, `logs_2.sqlite.${stamp}.sqlite`);
|
|
837
|
+
const tmpPath = `${backupPath}.tmp`;
|
|
838
|
+
try {
|
|
839
|
+
const sqlite = await trustedCommandPath("sqlite3");
|
|
840
|
+
const vacuum = await runCommand(sqlite, ["-init", "/dev/null", createSqliteUri(mainPath, "ro"), `VACUUM INTO '${tmpPath.replaceAll("'", "''")}';`], {
|
|
841
|
+
timeoutMs: 6e4
|
|
842
|
+
});
|
|
843
|
+
if (vacuum.code !== 0) throw new Error("backup failed");
|
|
844
|
+
await chmod2(tmpPath, 384);
|
|
845
|
+
const inspection = await inspectSqliteSnapshot(tmpPath);
|
|
846
|
+
if (inspection.quickCheck !== "ok") throw new Error("backup quick_check failed");
|
|
847
|
+
if (!inspection.recognizedSchema) throw new Error("backup schema is unsupported");
|
|
848
|
+
await rename(tmpPath, backupPath);
|
|
849
|
+
const manifestPath = `${backupPath}.manifest.json`;
|
|
850
|
+
await writeFile2(
|
|
851
|
+
manifestPath,
|
|
852
|
+
`${JSON.stringify(
|
|
853
|
+
{
|
|
854
|
+
toolVersion: TOOL_VERSION,
|
|
855
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
856
|
+
sourcePath: redactPath(mainPath),
|
|
857
|
+
backupPath: redactPath(backupPath),
|
|
858
|
+
inspection
|
|
859
|
+
},
|
|
860
|
+
null,
|
|
861
|
+
2
|
|
862
|
+
)}
|
|
863
|
+
`,
|
|
864
|
+
{ mode: 384, flag: "wx" }
|
|
865
|
+
);
|
|
866
|
+
await chmod2(manifestPath, 384);
|
|
867
|
+
return { path: backupPath, manifestPath };
|
|
868
|
+
} catch (error) {
|
|
869
|
+
await rm2(workDir, { recursive: true, force: true });
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function targetDirectoryBlockers(mainPath) {
|
|
874
|
+
const startDir = path6.dirname(mainPath);
|
|
875
|
+
const stopDir = path6.dirname(startDir);
|
|
876
|
+
const blockers = await assertDirectoryChainSafe(startDir, stopDir).catch((error) => [
|
|
877
|
+
error instanceof Error ? error.message : String(error)
|
|
878
|
+
]);
|
|
879
|
+
return blockers.map(redactPath);
|
|
880
|
+
}
|
|
881
|
+
async function runCheckpoint(sqlite, dbUri) {
|
|
882
|
+
const checkpoint = await runCommand(sqlite, [
|
|
883
|
+
"-json",
|
|
884
|
+
"-init",
|
|
885
|
+
"/dev/null",
|
|
886
|
+
dbUri,
|
|
887
|
+
"PRAGMA busy_timeout=0; PRAGMA wal_checkpoint(TRUNCATE);"
|
|
888
|
+
]);
|
|
889
|
+
if (checkpoint.code !== 0 || checkpoint.stdoutTruncated || checkpoint.stderrTruncated) {
|
|
890
|
+
throw new Error("checkpoint failed");
|
|
891
|
+
}
|
|
892
|
+
const rows = JSON.parse(checkpoint.stdout || "[]");
|
|
893
|
+
if (!checkpointRowsAreComplete(rows)) {
|
|
894
|
+
throw new Error("checkpoint busy");
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function checkpointRowsAreComplete(rows) {
|
|
898
|
+
return rows.length === 1 && typeof rows[0]?.busy === "number" && typeof rows[0]?.log === "number" && typeof rows[0]?.checkpointed === "number" && Number.isFinite(rows[0].busy) && Number.isFinite(rows[0].log) && Number.isFinite(rows[0].checkpointed) && rows[0].busy === 0 && rows[0].log === rows[0].checkpointed;
|
|
899
|
+
}
|
|
900
|
+
function baseFixReport(generatedAt) {
|
|
901
|
+
return {
|
|
902
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
903
|
+
toolVersion: TOOL_VERSION,
|
|
904
|
+
generatedAt,
|
|
905
|
+
command: "fix --safe",
|
|
906
|
+
status: "partial",
|
|
907
|
+
redacted: true,
|
|
908
|
+
target: {
|
|
909
|
+
kind: "default-codex-log-db",
|
|
910
|
+
pathCategory: "<home>/.codex/logs_2.sqlite"
|
|
911
|
+
},
|
|
912
|
+
findings: {},
|
|
913
|
+
metrics: {},
|
|
914
|
+
blockedReasons: []
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/restore.ts
|
|
919
|
+
import path7 from "path";
|
|
920
|
+
import { realpath as realpath2 } from "fs/promises";
|
|
921
|
+
async function validateRestoreBackup(backupPath) {
|
|
922
|
+
const backupRoot = path7.join(appDataHome(), "backups");
|
|
923
|
+
const rootBlockers = await assertExistingPrivateDirSafe(backupRoot);
|
|
924
|
+
if (rootBlockers.length > 0) return invalidRestoreResult(rootBlockers.join("; "));
|
|
925
|
+
const [backupRootReal, backupReal] = await Promise.all([
|
|
926
|
+
realpath2(backupRoot),
|
|
927
|
+
realpath2(backupPath)
|
|
928
|
+
]).catch(() => [void 0, void 0]);
|
|
929
|
+
if (!backupRootReal || !backupReal || !isInsideDirectory(backupReal, backupRootReal)) {
|
|
930
|
+
return invalidRestoreResult("backup is outside the tool backup directory");
|
|
931
|
+
}
|
|
932
|
+
const beforeBlockers = await assertSafeReadablePrivateFile(backupPath, "backup file");
|
|
933
|
+
if (beforeBlockers.length > 0) return invalidRestoreResult(beforeBlockers.join("; "));
|
|
934
|
+
const beforeIdentity = await collectFileIdentity(backupPath, "backup file");
|
|
935
|
+
const inspection = await inspectSqliteSnapshot(backupPath);
|
|
936
|
+
const afterBlockers = await assertSafeReadablePrivateFile(backupPath, "backup file");
|
|
937
|
+
if (afterBlockers.length > 0) return invalidRestoreResult("backup file changed during validation");
|
|
938
|
+
const afterIdentity = await collectFileIdentity(backupPath, "backup file");
|
|
939
|
+
if (!sameBackupIdentity(beforeIdentity, afterIdentity)) return invalidRestoreResult("backup file changed during validation");
|
|
940
|
+
return {
|
|
941
|
+
valid: inspection.quickCheck === "ok" && inspection.recognizedSchema,
|
|
942
|
+
inspection,
|
|
943
|
+
warnings: [
|
|
944
|
+
"Validation only. This command does not restore anything.",
|
|
945
|
+
"Do not move, copy, or replace database files unless following a recovery guide.",
|
|
946
|
+
"Ask for help before manual restore if you are not comfortable with SQLite files."
|
|
947
|
+
]
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
function invalidRestoreResult(reason) {
|
|
951
|
+
return {
|
|
952
|
+
valid: false,
|
|
953
|
+
reason,
|
|
954
|
+
warnings: [
|
|
955
|
+
"Validation only. This command does not restore anything.",
|
|
956
|
+
"Do not move, copy, or replace database files unless following a recovery guide.",
|
|
957
|
+
"Ask for help before manual restore if you are not comfortable with SQLite files."
|
|
958
|
+
]
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
function isInsideDirectory(candidate, root) {
|
|
962
|
+
return candidate === root || candidate.startsWith(`${root}${path7.sep}`);
|
|
963
|
+
}
|
|
964
|
+
function sameBackupIdentity(before, after) {
|
|
965
|
+
return before.exists === after.exists && before.regularFile === after.regularFile && before.symbolicLink === after.symbolicLink && before.dev === after.dev && before.ino === after.ino && before.nlink === after.nlink && before.mode === after.mode && before.uid === after.uid && before.gid === after.gid && before.size === after.size && before.mtimeMs === after.mtimeMs;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// src/cli.ts
|
|
969
|
+
async function runCli(argv = process.argv.slice(2)) {
|
|
970
|
+
const [command = "doctor", ...args] = argv;
|
|
971
|
+
const json = args.includes("--json");
|
|
972
|
+
const showPaths = args.includes("--show-paths");
|
|
973
|
+
if (command === "doctor") {
|
|
974
|
+
const flagError = unknownFlagError(args, /* @__PURE__ */ new Set(["--json", "--show-paths"]), "doctor");
|
|
975
|
+
if (flagError) return { exitCode: 2, output: flagError };
|
|
976
|
+
const { report, reportPath } = await runDoctor({ json, showPaths });
|
|
977
|
+
const outputReport = sanitizeReportForOutput(report);
|
|
978
|
+
return {
|
|
979
|
+
exitCode: report.status === "unsupported" ? 2 : 0,
|
|
980
|
+
output: json ? `${JSON.stringify(outputReport, null, 2)}
|
|
981
|
+
` : renderReport(outputReport, reportPath, showPaths)
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
if (command === "fix" && args.includes("--safe")) {
|
|
985
|
+
const confirmationError = fixSafeConfirmationError(args);
|
|
986
|
+
if (confirmationError) return { exitCode: 2, output: confirmationError };
|
|
987
|
+
const { report, reportPath } = await runFixSafe();
|
|
988
|
+
const outputReport = sanitizeReportForOutput(report);
|
|
989
|
+
const exitCode = report.status === "ok" ? 0 : 3;
|
|
990
|
+
return { exitCode, output: renderReport(outputReport, reportPath, showPaths) };
|
|
991
|
+
}
|
|
992
|
+
if (command === "report" && args.includes("--latest")) {
|
|
993
|
+
const flagError = unknownFlagError(args, /* @__PURE__ */ new Set(["--latest", "--show-paths", "--unredacted"]), "report");
|
|
994
|
+
if (flagError) return { exitCode: 2, output: flagError };
|
|
995
|
+
if (args.includes("--unredacted")) {
|
|
996
|
+
return { exitCode: 2, output: "--unredacted is not supported in v1.\n" };
|
|
997
|
+
}
|
|
998
|
+
const latest = await latestReport();
|
|
999
|
+
if (!latest) return { exitCode: 1, output: "No report found.\n" };
|
|
1000
|
+
const includePath = args.includes("--show-paths");
|
|
1001
|
+
const payload = latest.report;
|
|
1002
|
+
const pathLine = includePath ? `Report: ${redactPath(latest.path)}
|
|
1003
|
+
` : "";
|
|
1004
|
+
return { exitCode: 0, output: includePath ? `${pathLine}${JSON.stringify(payload, null, 2)}
|
|
1005
|
+
` : renderReport(latest.report, latest.path) };
|
|
1006
|
+
}
|
|
1007
|
+
if (command === "restore" && args[0] === "validate") {
|
|
1008
|
+
const flagError = unknownFlagError(args.slice(1), /* @__PURE__ */ new Set(["--backup"]), "restore validate");
|
|
1009
|
+
if (flagError) return { exitCode: 2, output: flagError };
|
|
1010
|
+
const backup = args[args.indexOf("--backup") + 1];
|
|
1011
|
+
if (!backup || backup === args[0]) return { exitCode: 2, output: "Missing --backup <path>.\n" };
|
|
1012
|
+
const result = await validateRestoreBackup(backup);
|
|
1013
|
+
return { exitCode: result.valid ? 0 : 3, output: `${JSON.stringify(result, null, 2)}
|
|
1014
|
+
` };
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
exitCode: 2,
|
|
1018
|
+
output: usageText()
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
function renderReport(report, reportPath, showPaths = false) {
|
|
1022
|
+
const lines = [
|
|
1023
|
+
`Status: ${report.status}`,
|
|
1024
|
+
`Safe to run fix --safe --yes: ${safeToRunFix(report) ? "yes" : "no"}`,
|
|
1025
|
+
`What changed: ${whatChanged(report)}`,
|
|
1026
|
+
`Target: ${report.target.pathCategory}`,
|
|
1027
|
+
`Blocked reasons: ${report.blockedReasons.length === 0 ? "none" : report.blockedReasons.join("; ")}`
|
|
1028
|
+
];
|
|
1029
|
+
if (report.metrics.reclaimedBytes !== void 0) {
|
|
1030
|
+
lines.push(`Reclaimed bytes: ${report.metrics.reclaimedBytes}`);
|
|
1031
|
+
}
|
|
1032
|
+
if (report.nextSafeAction) lines.push(`Next safe action: ${report.nextSafeAction}`);
|
|
1033
|
+
if (reportPath) {
|
|
1034
|
+
const reportLocation = showPaths ? redactPath(reportPath) : redactPath(reportPath);
|
|
1035
|
+
lines.push(`Report saved: ${reportLocation}`);
|
|
1036
|
+
lines.push(`Review with: npm exec --ignore-scripts ai-dev-maintenance@${TOOL_VERSION} -- report --latest`);
|
|
1037
|
+
}
|
|
1038
|
+
return `${lines.join("\n")}
|
|
1039
|
+
`;
|
|
1040
|
+
}
|
|
1041
|
+
function fixSafeConfirmationError(args) {
|
|
1042
|
+
const allowed = /* @__PURE__ */ new Set(["--safe", "--yes"]);
|
|
1043
|
+
const unknown = args.filter((arg) => arg.startsWith("-") && !allowed.has(arg));
|
|
1044
|
+
if (unknown.length > 0) return `Unknown fix flag: ${unknown.join(", ")}
|
|
1045
|
+
${usageText()}`;
|
|
1046
|
+
if (args.includes("--yes")) return void 0;
|
|
1047
|
+
return [
|
|
1048
|
+
"Missing required confirmation: --yes",
|
|
1049
|
+
"This creates a private local backup that may contain Codex log data, then cleans SQLite WAL storage.",
|
|
1050
|
+
"It will not upload data, print log contents, delete logs, or rewrite session history.",
|
|
1051
|
+
"Run again only after reviewing doctor output:",
|
|
1052
|
+
`npm exec --ignore-scripts ai-dev-maintenance@${TOOL_VERSION} -- fix --safe --yes`
|
|
1053
|
+
].join("\n") + "\n";
|
|
1054
|
+
}
|
|
1055
|
+
function unknownFlagError(args, allowed, command) {
|
|
1056
|
+
const unknown = args.filter((arg) => arg.startsWith("-") && !allowed.has(arg));
|
|
1057
|
+
return unknown.length > 0 ? `Unknown ${command} flag: ${unknown.join(", ")}
|
|
1058
|
+
${usageText()}` : void 0;
|
|
1059
|
+
}
|
|
1060
|
+
function usageText() {
|
|
1061
|
+
return [
|
|
1062
|
+
"Usage:",
|
|
1063
|
+
" ai-dev-maintenance doctor [--json] [--show-paths]",
|
|
1064
|
+
" ai-dev-maintenance fix --safe --yes",
|
|
1065
|
+
" ai-dev-maintenance report --latest [--show-paths]",
|
|
1066
|
+
" ai-dev-maintenance restore validate --backup <path>"
|
|
1067
|
+
].join("\n") + "\n";
|
|
1068
|
+
}
|
|
1069
|
+
function safeToRunFix(report) {
|
|
1070
|
+
if (report.command !== "doctor") return false;
|
|
1071
|
+
if (report.status !== "ok" || report.blockedReasons.length > 0) return false;
|
|
1072
|
+
const findings = report.findings;
|
|
1073
|
+
const openHandles = findings.openHandles;
|
|
1074
|
+
if (openHandles && (!openHandles.usable || openHandles.openHandles)) return false;
|
|
1075
|
+
if (findings.knownCodexProcessExists !== false) return false;
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
function whatChanged(report) {
|
|
1079
|
+
if (report.command === "doctor") return "redacted report only";
|
|
1080
|
+
if (report.command === "fix --safe" && report.status === "ok") return "private backup + WAL cleanup";
|
|
1081
|
+
if (report.command === "fix --safe" && report.metrics.backupCreated && report.metrics.checkpointAttempted) {
|
|
1082
|
+
return "private backup created + checkpoint attempted; review report";
|
|
1083
|
+
}
|
|
1084
|
+
if (report.command === "fix --safe" && report.metrics.backupCreated) return "private backup created; fix was blocked";
|
|
1085
|
+
if (report.command === "fix --safe") return "nothing; fix was blocked";
|
|
1086
|
+
return "nothing";
|
|
1087
|
+
}
|
|
1088
|
+
function isDirectCliInvocation(moduleUrl, argv1) {
|
|
1089
|
+
if (!argv1) return false;
|
|
1090
|
+
try {
|
|
1091
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argv1);
|
|
1092
|
+
} catch {
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (isDirectCliInvocation(import.meta.url, process.argv[1])) {
|
|
1097
|
+
runCli().then((result) => {
|
|
1098
|
+
process.stdout.write(result.output);
|
|
1099
|
+
process.exitCode = result.exitCode;
|
|
1100
|
+
}).catch((error) => {
|
|
1101
|
+
process.stderr.write(formatCliError(error));
|
|
1102
|
+
process.exitCode = 1;
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
function formatCliError(error) {
|
|
1106
|
+
return `${redactPath(error instanceof Error ? error.message : String(error))}
|
|
1107
|
+
`;
|
|
1108
|
+
}
|
|
1109
|
+
export {
|
|
1110
|
+
fixSafeConfirmationError,
|
|
1111
|
+
formatCliError,
|
|
1112
|
+
isDirectCliInvocation,
|
|
1113
|
+
renderReport,
|
|
1114
|
+
runCli
|
|
1115
|
+
};
|