catherd-cli 0.2.1 → 1.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/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spec §3.4: one frame to a Claude Code session's peer inbox (docs/research/2026-09-28-cross-session-messaging.md).
|
|
5
|
+
* The auth line (the child token the session gave its children), then one `user` frame, in one write; the socket
|
|
6
|
+
* is closed 150 ms later, as the native client does on macOS. No `session_id` (a stale one drops the frame) and no
|
|
7
|
+
* `from-mode` (a wrong one holds it).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SendOutcome = "sent" | "no-session" | "refused" | "error";
|
|
11
|
+
|
|
12
|
+
export interface SendResult {
|
|
13
|
+
outcome: SendOutcome;
|
|
14
|
+
/** the frame's msg_id, when one was written */
|
|
15
|
+
msgId?: string;
|
|
16
|
+
/** why it was not sent */
|
|
17
|
+
reason?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InboxTarget {
|
|
21
|
+
socketPath: string | null;
|
|
22
|
+
token: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How long a connection may take, and how long after the write the socket is closed; tests shorten them. */
|
|
26
|
+
export const inboxLimits = { connectMs: 2_000, closeAfterMs: 150 };
|
|
27
|
+
|
|
28
|
+
/** The two lines one send writes: the auth line (when there is a token) and the user frame. */
|
|
29
|
+
export function frameText(
|
|
30
|
+
content: string,
|
|
31
|
+
priority: "later" | "next",
|
|
32
|
+
token: string | null,
|
|
33
|
+
msgId: string,
|
|
34
|
+
): string {
|
|
35
|
+
const frame = { msgV: 1, msg_id: msgId, type: "user", message: { role: "user", content }, priority };
|
|
36
|
+
return `${token ? `${JSON.stringify({ type: "auth", token })}\n` : ""}${JSON.stringify(frame)}\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Sends one message; never throws: every failure is an outcome with its reason. */
|
|
40
|
+
export function sendToInbox(
|
|
41
|
+
target: InboxTarget,
|
|
42
|
+
content: string,
|
|
43
|
+
priority: "later" | "next",
|
|
44
|
+
): Promise<SendResult> {
|
|
45
|
+
if (!target.socketPath)
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
outcome: "no-session",
|
|
48
|
+
reason: "no messaging socket in this session's environment",
|
|
49
|
+
});
|
|
50
|
+
const msgId = crypto.randomUUID();
|
|
51
|
+
const socketPath = target.socketPath;
|
|
52
|
+
return new Promise<SendResult>((resolve) => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
const done = (r: SendResult) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
resolve(r);
|
|
58
|
+
};
|
|
59
|
+
let s: ReturnType<typeof createConnection>;
|
|
60
|
+
try {
|
|
61
|
+
s = createConnection({ path: socketPath });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return done({ outcome: "error", reason: (e as Error).message });
|
|
64
|
+
}
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
s.destroy();
|
|
67
|
+
done({ outcome: "error", reason: `no connection within ${inboxLimits.connectMs} ms` });
|
|
68
|
+
}, inboxLimits.connectMs);
|
|
69
|
+
s.on("error", (e: NodeJS.ErrnoException) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
s.destroy();
|
|
72
|
+
if (e.code === "ENOENT")
|
|
73
|
+
done({ outcome: "no-session", reason: `the session's socket is gone (${socketPath})` });
|
|
74
|
+
else if (e.code === "ECONNREFUSED")
|
|
75
|
+
done({ outcome: "refused", reason: "the session's socket refused it" });
|
|
76
|
+
else done({ outcome: "error", reason: e.code ?? e.message });
|
|
77
|
+
});
|
|
78
|
+
s.on("connect", () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
s.write(frameText(content, priority, target.token, msgId), (err) => {
|
|
81
|
+
if (err) {
|
|
82
|
+
s.destroy();
|
|
83
|
+
return done({ outcome: "error", reason: err.message });
|
|
84
|
+
}
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
s.end();
|
|
87
|
+
done({ outcome: "sent", msgId });
|
|
88
|
+
}, inboxLimits.closeAfterMs);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { scrubSecrets } from "./env.ts";
|
|
3
|
+
|
|
4
|
+
/** A string that differs between two processes that held the same pid: /proc starttime, else `ps lstart`. */
|
|
5
|
+
export function processStartTime(pid: number): string | null {
|
|
6
|
+
try {
|
|
7
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
8
|
+
// Field 22 (starttime); the command name in field 2 may hold spaces, so split after its ')'.
|
|
9
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
10
|
+
return fields[19] ?? null;
|
|
11
|
+
} catch {
|
|
12
|
+
return psStartTime(pid);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let ownStart: string | null = null;
|
|
17
|
+
/**
|
|
18
|
+
* This process's own identity for the leases and claims it writes. The start time is read once and kept:
|
|
19
|
+
* on macOS `ps` can fail for a moment, and a second read must never make a process a stranger to itself.
|
|
20
|
+
*/
|
|
21
|
+
export function selfIdentity(): { pid: number; startTime: string | null } {
|
|
22
|
+
ownStart ??= processStartTime(process.pid);
|
|
23
|
+
return { pid: process.pid, startTime: ownStart };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `ps` by absolute path where it lives, so a caller with a narrow PATH (a test, a sandbox) still finds it. */
|
|
27
|
+
const PS = existsSync("/bin/ps") ? "/bin/ps" : "ps";
|
|
28
|
+
|
|
29
|
+
/** `ps lstart` for a pid, or null when ps is missing or fails (macOS has no /proc). */
|
|
30
|
+
export function psStartTime(
|
|
31
|
+
pid: number,
|
|
32
|
+
env: Record<string, string | undefined> = process.env,
|
|
33
|
+
): string | null {
|
|
34
|
+
try {
|
|
35
|
+
// lstart is printed in the local time zone and locale: pin both so every process agrees.
|
|
36
|
+
const ps = Bun.spawnSync([PS, "-o", "lstart=", "-p", String(pid)], {
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "ignore",
|
|
39
|
+
env: { ...scrubSecrets(env), LC_ALL: "C", TZ: "UTC" },
|
|
40
|
+
});
|
|
41
|
+
const out = ps.success ? ps.stdout.toString("utf8").trim() : "";
|
|
42
|
+
return out || null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Only an integer >= 1 names one real process: 0 and negatives address groups. Pid 1 is valid
|
|
50
|
+
* (catherd may be a container's entrypoint); only killGroup refuses it, since kill(-1) signals everything.
|
|
51
|
+
*/
|
|
52
|
+
export const isValidPid = (pid: unknown): pid is number => Number.isInteger(pid) && (pid as number) >= 1;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a live pid is still the process recorded with `recorded`. A start time that cannot be read now
|
|
56
|
+
* (`ps` fails for a moment on macOS) counts as the same process: a live lock holder or worker is never
|
|
57
|
+
* taken for dead, and the worst case is a lock that times out with E_IO_LOCK instead.
|
|
58
|
+
*/
|
|
59
|
+
export const sameProcess = (recorded: string | null, current: string | null): boolean =>
|
|
60
|
+
recorded === null || current === null || current === recorded;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* For a signal only: the pid is live and its start time reads back exactly as recorded. An unreadable or
|
|
64
|
+
* unrecorded start time is not a match, since the pid may belong to another process by now.
|
|
65
|
+
*/
|
|
66
|
+
export const surelySame = (recorded: string | null, current: string | null): boolean =>
|
|
67
|
+
recorded !== null && current !== null && current === recorded;
|
|
68
|
+
|
|
69
|
+
export function isSurelyAlive(pid: number, startTime: string | null): boolean {
|
|
70
|
+
return isAlive(pid, startTime) && surelySame(startTime, processStartTime(pid));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether /proc shows the pid as a zombie: dead, only never reaped (a pid 1 that reaps no orphans). */
|
|
74
|
+
function isZombie(pid: number): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
77
|
+
return stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3) === "Z";
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isAlive(pid: number, startTime: string | null): boolean {
|
|
84
|
+
if (!isValidPid(pid)) return false;
|
|
85
|
+
try {
|
|
86
|
+
process.kill(pid, 0);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if ((e as NodeJS.ErrnoException).code !== "EPERM") return false;
|
|
89
|
+
}
|
|
90
|
+
if (isZombie(pid)) return false;
|
|
91
|
+
return startTime === null || sameProcess(startTime, processStartTime(pid));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Signals the process group a detached child leads (pgid === pid), falling back to the pid alone. No
|
|
96
|
+
* group (ESRCH) means its leader is gone too, and that pid may belong to another process by now.
|
|
97
|
+
*/
|
|
98
|
+
export function killGroup(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
99
|
+
if (!isValidPid(pid) || pid === 1) return;
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-pid, signal);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if ((e as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, signal);
|
|
106
|
+
} catch {
|
|
107
|
+
// already gone
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { dirname, sep } from "node:path";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { CatherdError, UPGRADE } from "../domain/errors.ts";
|
|
19
|
+
import { configDir, dataDir } from "./paths.ts";
|
|
20
|
+
|
|
21
|
+
// Audit S2: catherd's dirs hold briefs, argv, stderr tails and logs, so they are 0700 and its files 0600.
|
|
22
|
+
export const PRIVATE_DIR = 0o700;
|
|
23
|
+
export const PRIVATE_FILE = 0o600;
|
|
24
|
+
|
|
25
|
+
const tightened = new Set<string>();
|
|
26
|
+
/** Once per process per dir: `dir` at 0700, if an older catherd left it open to others. */
|
|
27
|
+
function tighten(dir: string): void {
|
|
28
|
+
if (tightened.has(dir)) return;
|
|
29
|
+
tightened.add(dir);
|
|
30
|
+
try {
|
|
31
|
+
if (statSync(dir).mode & 0o077) chmodSync(dir, PRIVATE_DIR);
|
|
32
|
+
} catch {
|
|
33
|
+
// not ours to fix (another owner, a read-only mount): the files inside are still 0600
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `dir` and any missing parent, created 0700. `mkdirSync` leaves an existing dir as it is, so under
|
|
39
|
+
* catherd's config or data dir, `dir` and every dir up to that root are tightened to 0700 (a 0.x install).
|
|
40
|
+
*/
|
|
41
|
+
export function ensurePrivateDir(dir: string): void {
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR });
|
|
43
|
+
for (const root of [configDir(), dataDir()]) {
|
|
44
|
+
if (dir !== root && !dir.startsWith(root + sep)) continue;
|
|
45
|
+
for (let d = dir; d.length >= root.length; d = dirname(d)) {
|
|
46
|
+
tighten(d);
|
|
47
|
+
if (d === root) break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `file` at 0600 when it exists and others may read it: a file another program wrote into catherd's dirs. */
|
|
53
|
+
export function makePrivate(file: string): void {
|
|
54
|
+
try {
|
|
55
|
+
if (statSync(file).mode & 0o077) chmodSync(file, PRIVATE_FILE);
|
|
56
|
+
} catch {
|
|
57
|
+
// missing, or not ours: its dir is 0700 all the same
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `mode` (0600 by default) is the file's exact permission bits (umask aside), set before any byte is written. */
|
|
62
|
+
export function writeTextAtomic(file: string, text: string, o: { mode?: number } = {}): void {
|
|
63
|
+
const mode = o.mode ?? PRIVATE_FILE;
|
|
64
|
+
ensurePrivateDir(dirname(file));
|
|
65
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
66
|
+
const fd = openSync(tmp, "w", mode);
|
|
67
|
+
try {
|
|
68
|
+
fchmodSync(fd, mode);
|
|
69
|
+
writeSync(fd, text);
|
|
70
|
+
fsyncSync(fd);
|
|
71
|
+
} finally {
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const writeJsonAtomic = (file: string, value: unknown, o: { mode?: number } = {}): void =>
|
|
78
|
+
writeTextAtomic(file, `${JSON.stringify(value, null, 2)}\n`, o);
|
|
79
|
+
|
|
80
|
+
function newer(file: string, found: number, current: number): CatherdError {
|
|
81
|
+
return new CatherdError(
|
|
82
|
+
"E_CONFIG_NEWER_SCHEMA",
|
|
83
|
+
`${file} has schema ${found}, newer than this catherd (${current})`,
|
|
84
|
+
{
|
|
85
|
+
fix: `upgrade catherd: ${UPGRADE}`,
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The parsed content of a JSON file catherd keeps. The error never carries the parser's message: it
|
|
92
|
+
* quotes the offending text, which for a key pasted into credentials.json is the key itself (audit B1).
|
|
93
|
+
*/
|
|
94
|
+
export function readJsonFile(file: string, fix = `fix or delete ${file}`): unknown {
|
|
95
|
+
let text: string;
|
|
96
|
+
try {
|
|
97
|
+
text = readFileSync(file, "utf8");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
const code = (e as NodeJS.ErrnoException).code ?? "an I/O error";
|
|
100
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} cannot be read (${code})`, { fix });
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(text);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} is not valid JSON`, { fix });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reads a `"schema": n` JSON file. Schemas are loose objects, so unknown fields survive a rewrite.
|
|
111
|
+
* `fix` replaces the default fix of an unreadable or invalid file.
|
|
112
|
+
*/
|
|
113
|
+
export function readVersioned<T>(
|
|
114
|
+
file: string,
|
|
115
|
+
schema: z.ZodType<T>,
|
|
116
|
+
current: number,
|
|
117
|
+
o: { fix?: string } = {},
|
|
118
|
+
): T {
|
|
119
|
+
const fix = o.fix ?? `fix or delete ${file}`;
|
|
120
|
+
const raw = readJsonFile(file, fix);
|
|
121
|
+
const found = (raw as { schema?: unknown } | null)?.schema;
|
|
122
|
+
if (typeof found === "number" && found > current) throw newer(file, found, current);
|
|
123
|
+
const r = schema.safeParse(raw);
|
|
124
|
+
if (!r.success)
|
|
125
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} is invalid:\n${z.prettifyError(r.error)}`, { fix });
|
|
126
|
+
return r.data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True when `file` exists, is non-empty and does not end in a newline (a crash-truncated tail). */
|
|
130
|
+
function endsMidLine(file: string): boolean {
|
|
131
|
+
let size: number;
|
|
132
|
+
try {
|
|
133
|
+
size = statSync(file).size;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (size === 0) return false;
|
|
138
|
+
const fd = openSync(file, "r");
|
|
139
|
+
try {
|
|
140
|
+
const last = Buffer.alloc(1);
|
|
141
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
142
|
+
return last[0] !== 0x0a;
|
|
143
|
+
} finally {
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One `appendFileSync` per row, so a crash can only ever truncate the last line. A truncated
|
|
150
|
+
* tail left by an earlier crash is ended first, in the same write, so the new row stays whole.
|
|
151
|
+
*/
|
|
152
|
+
export function appendJsonl(file: string, value: unknown): void {
|
|
153
|
+
ensurePrivateDir(dirname(file));
|
|
154
|
+
const prefix = endsMidLine(file) ? "\n" : "";
|
|
155
|
+
appendPrivate(file, `${prefix}${JSON.stringify(value)}\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const madePrivate = new Set<string>();
|
|
159
|
+
/**
|
|
160
|
+
* Appends `text` to `file`, created 0600. `appendFileSync`'s mode only applies to a new file, so one
|
|
161
|
+
* an older catherd left open to others is tightened first, once per process.
|
|
162
|
+
*/
|
|
163
|
+
export function appendPrivate(file: string, text: string): void {
|
|
164
|
+
ensurePrivateDir(dirname(file));
|
|
165
|
+
if (!madePrivate.has(file)) {
|
|
166
|
+
makePrivate(file);
|
|
167
|
+
madePrivate.add(file);
|
|
168
|
+
}
|
|
169
|
+
appendFileSync(file, text, { mode: PRIVATE_FILE });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function ensureJsonlHeader(file: string, kind: string): void {
|
|
173
|
+
ensurePrivateDir(dirname(file));
|
|
174
|
+
try {
|
|
175
|
+
// O_APPEND: a row another process appends between the create and this write is kept, not overwritten
|
|
176
|
+
const fd = openSync(file, "ax", PRIVATE_FILE);
|
|
177
|
+
try {
|
|
178
|
+
writeSync(fd, `${JSON.stringify({ schema: 1, kind })}\n`);
|
|
179
|
+
} finally {
|
|
180
|
+
closeSync(fd);
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {
|
|
183
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const isHeader = (v: unknown): v is { schema: number; kind: string } =>
|
|
188
|
+
typeof v === "object" &&
|
|
189
|
+
v !== null &&
|
|
190
|
+
Object.keys(v).length === 2 &&
|
|
191
|
+
typeof (v as { schema?: unknown }).schema === "number" &&
|
|
192
|
+
typeof (v as { kind?: unknown }).kind === "string";
|
|
193
|
+
|
|
194
|
+
/** The non-blank lines of `file`; none when it cannot be read (missing, or not written yet). */
|
|
195
|
+
export function nonBlankLines(file: string): string[] {
|
|
196
|
+
let text: string;
|
|
197
|
+
try {
|
|
198
|
+
text = readFileSync(file, "utf8");
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
return text.split("\n").filter((l) => l.trim());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function readJsonl<T>(file: string, current = 1): { kind: string | null; rows: T[]; corrupt: number } {
|
|
206
|
+
if (!existsSync(file)) return { kind: null, rows: [], corrupt: 0 };
|
|
207
|
+
const rows: T[] = [];
|
|
208
|
+
let kind: string | null = null;
|
|
209
|
+
let corrupt = 0;
|
|
210
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
211
|
+
lines.forEach((line) => {
|
|
212
|
+
if (!line.trim()) return;
|
|
213
|
+
let v: unknown;
|
|
214
|
+
try {
|
|
215
|
+
v = JSON.parse(line);
|
|
216
|
+
} catch {
|
|
217
|
+
corrupt++;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// the header is first unless another writer's row won the race to a new file (ensureJsonlHeader)
|
|
221
|
+
if (kind === null && isHeader(v)) {
|
|
222
|
+
if (v.schema > current) throw newer(file, v.schema, current);
|
|
223
|
+
kind = v.kind;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
rows.push(v as T);
|
|
227
|
+
});
|
|
228
|
+
return { kind, rows, corrupt };
|
|
229
|
+
}
|