@splinterzzz/ouro 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.
@@ -0,0 +1,204 @@
1
+ import { spawn } from "node:child_process";
2
+ import readline from "node:readline";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * NOTE on the JSONL event shape: `codex exec --json` emits one JSON object
9
+ * per line to stdout. Field names below (`type`, `item.text`, `msg`, etc.)
10
+ * are based on public docs/community usage as of build time, not a locked
11
+ * spec — Codex's event schema has moved before. First thing to do when you
12
+ * sit down: run `codex exec --json "say hello"` once and diff the real
13
+ * output against the parsing in `parseLine()` below, then adjust. Don't
14
+ * build the rest of the pipeline on top of an unverified assumption here.
15
+ */
16
+
17
+ const CODEX_BIN = process.env.OURO_CODEX_BIN || "codex";
18
+
19
+ const TRIAGE_SCHEMA = {
20
+ type: "object",
21
+ properties: {
22
+ summary: { type: "string" },
23
+ priority: { type: "string", enum: ["low", "medium", "high"] },
24
+ files_likely_affected: { type: "array", items: { type: "string" } },
25
+ },
26
+ required: ["summary", "priority", "files_likely_affected"],
27
+ additionalProperties: false,
28
+ };
29
+
30
+ function parseLine(line) {
31
+ try {
32
+ return JSON.parse(line);
33
+ } catch {
34
+ // Non-JSON stderr-ish noise sometimes rides along on stdout depending on
35
+ // version; surface it as a raw event instead of crashing the parser.
36
+ return { type: "raw", text: line };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Codex takes the agent's system prompt as a config override rather than a
42
+ * flag, and has no per-tool grant model — the sandbox mode governs writes.
43
+ * So an agent's tool list is advisory on this backend; it's the Claude Code
44
+ * path (--allowedTools) where it's enforced.
45
+ */
46
+ function agentFlags(agent) {
47
+ if (!agent) return [];
48
+ const flags = [];
49
+ if (agent.model) flags.push("--model", agent.model);
50
+ if (agent.systemPrompt?.trim()) {
51
+ flags.push("-c", `experimental_instructions=${JSON.stringify(agent.systemPrompt.trim())}`);
52
+ }
53
+ return flags;
54
+ }
55
+
56
+ /**
57
+ * Runs `codex exec` and streams parsed JSONL events to `onEvent`.
58
+ * Resolves with { code, sessionId, lastMessage } when the process exits.
59
+ */
60
+ function runCodexExec(args, { cwd, onEvent, signal } = {}) {
61
+ return new Promise((resolve, reject) => {
62
+ // `signal` makes cancellation kill the child process. See lib/runs.js.
63
+ const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal });
64
+
65
+ const rl = readline.createInterface({ input: proc.stdout });
66
+ let sessionId = null;
67
+ let lastMessage = null;
68
+
69
+ rl.on("line", (line) => {
70
+ if (!line.trim()) return;
71
+ const event = parseLine(line);
72
+ if (event.session_id) sessionId = event.session_id;
73
+ if (event.type === "result" || event.subtype === "success") {
74
+ lastMessage = event.result ?? event.text ?? lastMessage;
75
+ }
76
+ onEvent?.(event);
77
+ });
78
+
79
+ let stderrBuf = "";
80
+ proc.stderr.on("data", (chunk) => {
81
+ stderrBuf += chunk.toString();
82
+ onEvent?.({ type: "stderr", text: chunk.toString() });
83
+ });
84
+
85
+ proc.on("error", (err) => {
86
+ // Abort is an expected cancel, not a crash — resolve so the route can
87
+ // mark the ticket cancelled instead of erroring.
88
+ if (err.name === "AbortError" || signal?.aborted) {
89
+ resolve({ code: null, sessionId, lastMessage, stderr: stderrBuf, aborted: true });
90
+ return;
91
+ }
92
+ reject(err);
93
+ });
94
+
95
+ proc.on("close", (code) => {
96
+ resolve({ code, sessionId, lastMessage, stderr: stderrBuf, aborted: Boolean(signal?.aborted) });
97
+ });
98
+ });
99
+ }
100
+
101
+ function parseJsonish(text) {
102
+ const cleaned = String(text ?? "").replace(/```json|```/g, "").trim();
103
+ try {
104
+ return JSON.parse(cleaned);
105
+ } catch {
106
+ // Fall back to the outermost {...} in case prose leaked in around it.
107
+ const start = cleaned.indexOf("{");
108
+ const end = cleaned.lastIndexOf("}");
109
+ if (start !== -1 && end > start) {
110
+ try {
111
+ return JSON.parse(cleaned.slice(start, end + 1));
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /** Read-only JSON-in/JSON-out call — backs the Telegram intake interview. */
121
+ export async function askJson({ prompt, cwd, signal }) {
122
+ const { lastMessage } = await runCodexExec(["exec", prompt, "--json", "--sandbox", "read-only"], { cwd, signal });
123
+ return parseJsonish(lastMessage);
124
+ }
125
+
126
+ /**
127
+ * Triage: read-only, schema-constrained call. Cheap, safe, no repo writes.
128
+ */
129
+ export async function triage({ prompt, cwd, signal }) {
130
+ // os.tmpdir(), not a hardcoded /tmp — this has to work on Windows too. The
131
+ // pid suffix keeps concurrent triages off one another's schema file.
132
+ const schemaPath = path.join(os.tmpdir(), `ouro-triage-schema-${process.pid}.json`);
133
+ fs.writeFileSync(schemaPath, JSON.stringify(TRIAGE_SCHEMA));
134
+
135
+ try {
136
+ const { lastMessage } = await runCodexExec(
137
+ ["exec", prompt, "--json", "--sandbox", "read-only", "--output-schema", schemaPath],
138
+ { cwd, signal }
139
+ );
140
+
141
+ return (
142
+ parseJsonish(lastMessage) ?? {
143
+ summary: lastMessage ?? "(no summary returned)",
144
+ priority: "medium",
145
+ files_likely_affected: [],
146
+ }
147
+ );
148
+ } finally {
149
+ fs.rmSync(schemaPath, { force: true });
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Agent mode: full autonomy within the worktree, workspace-write sandbox,
155
+ * auto-approved. `onEvent` should be wired to the ticket's log + WS broadcast.
156
+ */
157
+ export function runAgent({ prompt, cwd, onEvent, signal, agent }) {
158
+ return runCodexExec(
159
+ ["exec", prompt, "--json", "--sandbox", "workspace-write", "--full-auto", ...agentFlags(agent)],
160
+ { cwd, onEvent, signal }
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Human-in-the-loop, phase 1: plan only, read-only sandbox, no writes.
166
+ * Non-interactive headless mode can't reliably pause mid-run for approval
167
+ * (an unapproved action just fails the run rather than blocking on it), so
168
+ * instead we do two full calls: plan (read-only) -> show on card -> approve
169
+ * -> execute (write-enabled, resumed from the same session).
170
+ */
171
+ export function planTicket({ prompt, cwd, onEvent, signal, agent }) {
172
+ return runCodexExec(
173
+ [
174
+ "exec",
175
+ `${prompt}\n\nDo not edit any files yet. First produce a short numbered plan of what you'd change and why.`,
176
+ "--json",
177
+ "--sandbox",
178
+ "read-only",
179
+ ...agentFlags(agent),
180
+ ],
181
+ { cwd, onEvent, signal }
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Human-in-the-loop, phase 2: resume the planning session with a
187
+ * write-enabled sandbox, after the person clicks Approve.
188
+ */
189
+ export function executeTicket({ cwd, sessionId, onEvent, signal, agent }) {
190
+ return runCodexExec(
191
+ [
192
+ "exec",
193
+ "resume",
194
+ sessionId ?? "--last",
195
+ "Approved. Implement the plan now and run relevant tests.",
196
+ "--json",
197
+ "--sandbox",
198
+ "workspace-write",
199
+ "--full-auto",
200
+ ...agentFlags(agent),
201
+ ],
202
+ { cwd, onEvent, signal }
203
+ );
204
+ }
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs";
2
+ import { configPath } from "./paths.js";
3
+ import { maskToken } from "./telegram.js";
4
+
5
+ // `.ouro/config.json` is the one piece of state a human is expected to hand-edit,
6
+ // so reads are always fresh from disk and writes merge rather than replace —
7
+ // an unknown key someone added by hand survives a dashboard toggle.
8
+
9
+ const DEFAULTS = {
10
+ version: 1,
11
+ backend: "claude-code",
12
+ defaultMode: "human", // safe default: plan first, write only after approval
13
+ // Commit + push + open a PR automatically once a run finishes with changes.
14
+ // On by default so a run ends somewhere a human can review it, rather than
15
+ // as an unpushed branch in a local worktree. Set false to require the
16
+ // explicit "Create PR" button instead — pushing is outward-facing, so this
17
+ // is deliberately a knob and not a hardcode.
18
+ autoShip: true,
19
+ telegram: {
20
+ botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
21
+ chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
22
+ },
23
+ };
24
+
25
+ export function readConfig() {
26
+ try {
27
+ return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(configPath(), "utf-8")) };
28
+ } catch {
29
+ return { ...DEFAULTS };
30
+ }
31
+ }
32
+
33
+ export function writeConfig(patch) {
34
+ const next = { ...readConfig(), ...patch };
35
+ fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
36
+ return next;
37
+ }
38
+
39
+ export const DEFAULT_TELEGRAM_TOKEN_VAR = "OURO_TELEGRAM_BOT_TOKEN";
40
+
41
+ const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
+
43
+ /**
44
+ * The *name* of the env var holding the bot token — never the token itself.
45
+ *
46
+ * Returns `{ name, error? }`. The error case is a real one people hit: the key
47
+ * reads like somewhere to put a token, so a token goes in it, and every caller
48
+ * then looks up process.env["8123:AA…"], finds undefined, and reports
49
+ * "8123:AA… is not set" — which blames the token for the config's mistake.
50
+ * Worse, config.json is committed on purpose (that's the point of
51
+ * agents-as-files), so a token parked here is a token headed for git history.
52
+ */
53
+ export function telegramTokenVar() {
54
+ const value = readConfig().telegram?.botTokenEnvVar;
55
+ if (!value) return { name: DEFAULT_TELEGRAM_TOKEN_VAR };
56
+ if (ENV_VAR_NAME.test(value)) return { name: value };
57
+
58
+ // Newlines rather than one long string: this is printed to a terminal by two
59
+ // commands and rendered in the dashboard, and none of them own a wrapper.
60
+ return {
61
+ name: DEFAULT_TELEGRAM_TOKEN_VAR,
62
+ error: [
63
+ `.ouro/config.json has telegram.botTokenEnvVar set to "${maskToken(value)}".`,
64
+ `That field takes the NAME of the env var holding your token — "${DEFAULT_TELEGRAM_TOKEN_VAR}" — not the token itself.`,
65
+ `config.json is committed on purpose, so treat that token as exposed: revoke it via @BotFather, set the field back`,
66
+ `to "${DEFAULT_TELEGRAM_TOKEN_VAR}", and put the new token in .ouro/.env (gitignored) or the dashboard's Settings screen.`,
67
+ ].join("\n"),
68
+ };
69
+ }
70
+
71
+ export const MODES = ["human", "agent"];
72
+
73
+ export function getDefaultMode() {
74
+ const mode = readConfig().defaultMode;
75
+ return MODES.includes(mode) ? mode : "human";
76
+ }
77
+
78
+ export function setDefaultMode(mode) {
79
+ if (!MODES.includes(mode)) throw new Error(`Unknown mode: ${mode}`);
80
+ writeConfig({ defaultMode: mode });
81
+ return mode;
82
+ }
83
+
84
+ export function getAutoShip() {
85
+ return readConfig().autoShip !== false;
86
+ }
87
+
88
+ export function setAutoShip(on) {
89
+ writeConfig({ autoShip: Boolean(on) });
90
+ return Boolean(on);
91
+ }
@@ -0,0 +1,235 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { runDir, logsDir, repoRoot, ensureOuroDir } from "./paths.js";
6
+
7
+ // Background process supervision for `ouro start` / `ouro stop`.
8
+ //
9
+ // Deliberately not pm2/forever/systemd: ouro is a single-operator tool that
10
+ // roots into one repo, and a pid file plus a detached spawn is the whole job.
11
+ // Adding a process manager would mean a global daemon with its own state,
12
+ // which is exactly the thing ouro avoids everywhere else.
13
+ //
14
+ // The two services:
15
+ // dashboard — Express + WS, holds ticket state, spawns agent CLIs
16
+ // listen — Telegram intake agent, talks to the dashboard over HTTP
17
+ // They're separate processes so restarting the bot can't drop board state,
18
+ // which is the same split the foreground commands already had.
19
+
20
+ export const SERVICES = ["dashboard", "listen"];
21
+
22
+ const CLI_ENTRY = fileURLToPath(new URL("../index.js", import.meta.url));
23
+
24
+ // A 24/7 process writes logs forever. Rotate one generation at start; two
25
+ // files bounded at 5MB is plenty to debug "why did the bot stop overnight",
26
+ // and beats either unbounded growth or a logrotate dependency.
27
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
28
+
29
+ function pidFile(name) {
30
+ return path.join(runDir(), `${name}.json`);
31
+ }
32
+
33
+ export function logFile(name) {
34
+ return path.join(logsDir(), `${name}.log`);
35
+ }
36
+
37
+ export function readRecord(name) {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(pidFile(name), "utf-8"));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function writeRecord(name, record) {
46
+ ensureOuroDir();
47
+ fs.writeFileSync(pidFile(name), JSON.stringify(record, null, 2));
48
+ }
49
+
50
+ export function clearRecord(name) {
51
+ fs.rmSync(pidFile(name), { force: true });
52
+ }
53
+
54
+ /** Merges fields into an existing record — e.g. the port, once it's confirmed. */
55
+ export function updateRecord(name, patch) {
56
+ const current = readRecord(name);
57
+ if (!current) return null;
58
+ const next = { ...current, ...patch };
59
+ writeRecord(name, next);
60
+ return next;
61
+ }
62
+
63
+ /** Signal 0 probes for existence without delivering anything. */
64
+ export function isAlive(pid) {
65
+ if (!pid) return false;
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (err) {
70
+ // EPERM means it exists but is owned by someone else — still alive.
71
+ return err.code === "EPERM";
72
+ }
73
+ }
74
+
75
+ /**
76
+ * A pid file whose process is gone is stale — from a reboot, or a crash. Clean
77
+ * it up on read so `status` and `start` never trust a recycled pid.
78
+ */
79
+ export function serviceStatus(name) {
80
+ const record = readRecord(name);
81
+ if (!record) return { name, running: false };
82
+
83
+ if (!isAlive(record.pid)) {
84
+ clearRecord(name);
85
+ return { name, running: false, stale: true };
86
+ }
87
+ return { name, running: true, ...record };
88
+ }
89
+
90
+ export function statusAll() {
91
+ return SERVICES.map(serviceStatus);
92
+ }
93
+
94
+ function rotate(file) {
95
+ try {
96
+ if (fs.statSync(file).size > MAX_LOG_BYTES) fs.renameSync(file, `${file}.1`);
97
+ } catch {
98
+ // No log yet, or a Windows lock — not worth failing a start over.
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Spawns a service detached from this terminal.
104
+ *
105
+ * `detached: true` puts the child in its own process group (POSIX) / process
106
+ * tree root (Windows), which is what lets it outlive the shell — and what
107
+ * lets stopService kill its agent grandchildren later. stdout/stderr go to a
108
+ * file rather than a pipe, because a pipe with no reader fills its buffer and
109
+ * wedges the child once the parent exits.
110
+ */
111
+ export function startService(name, args = []) {
112
+ ensureOuroDir();
113
+ const file = logFile(name);
114
+ rotate(file);
115
+
116
+ // Where this run's output starts. Logs are append-only across restarts, so
117
+ // without this a failed start would tail yesterday's errors and send you
118
+ // debugging a problem you already fixed.
119
+ const logOffset = fs.existsSync(file) ? fs.statSync(file).size : 0;
120
+
121
+ const fd = fs.openSync(file, "a");
122
+ try {
123
+ const child = spawn(process.execPath, [CLI_ENTRY, name, ...args], {
124
+ cwd: repoRoot(),
125
+ detached: true,
126
+ windowsHide: true, // no flashing console window on Windows
127
+ stdio: ["ignore", fd, fd],
128
+ env: process.env,
129
+ });
130
+ child.unref(); // don't hold this CLI's event loop open
131
+
132
+ const record = { pid: child.pid, startedAt: new Date().toISOString(), args, logOffset };
133
+ writeRecord(name, record);
134
+ return record;
135
+ } finally {
136
+ fs.closeSync(fd); // the child holds its own dup of the fd
137
+ }
138
+ }
139
+
140
+ function killTree(pid) {
141
+ if (process.platform === "win32") {
142
+ // /T kills the whole tree. Without it, a running `claude` child is
143
+ // orphaned and keeps burning subscription tokens with nothing watching it.
144
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
145
+ return;
146
+ }
147
+ // Negative pid targets the process group — same reasoning as /T above.
148
+ try {
149
+ process.kill(-pid, "SIGTERM");
150
+ } catch {
151
+ try {
152
+ process.kill(pid, "SIGTERM");
153
+ } catch {
154
+ // already gone
155
+ }
156
+ }
157
+ }
158
+
159
+ function forceKillTree(pid) {
160
+ if (process.platform === "win32") return; // taskkill /F was already forceful
161
+ try {
162
+ process.kill(-pid, "SIGKILL");
163
+ } catch {
164
+ try {
165
+ process.kill(pid, "SIGKILL");
166
+ } catch {
167
+ // already gone
168
+ }
169
+ }
170
+ }
171
+
172
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
173
+
174
+ /**
175
+ * Stops a service and its children. Returns what actually happened so the
176
+ * command can report honestly rather than always printing "stopped".
177
+ */
178
+ export async function stopService(name, { timeoutMs = 5000 } = {}) {
179
+ const record = readRecord(name);
180
+ if (!record) return { name, stopped: false, reason: "not running" };
181
+
182
+ if (!isAlive(record.pid)) {
183
+ clearRecord(name);
184
+ return { name, stopped: false, reason: "stale pid file (process already gone)" };
185
+ }
186
+
187
+ killTree(record.pid);
188
+
189
+ // Give it a moment to go down cleanly; the dashboard flushes its store and
190
+ // cancels in-flight runs on SIGTERM (POSIX). Windows has no real signals, so
191
+ // taskkill /F is immediate — the store's debounced write and the
192
+ // in_progress→cancelled reconciliation on next boot cover that gap.
193
+ const deadline = Date.now() + timeoutMs;
194
+ while (Date.now() < deadline) {
195
+ if (!isAlive(record.pid)) {
196
+ clearRecord(name);
197
+ return { name, stopped: true, pid: record.pid };
198
+ }
199
+ await sleep(100);
200
+ }
201
+
202
+ forceKillTree(record.pid);
203
+ await sleep(200);
204
+
205
+ const gone = !isAlive(record.pid);
206
+ if (gone) clearRecord(name);
207
+ return { name, stopped: gone, pid: record.pid, forced: true, ...(gone ? {} : { reason: "refused to die" }) };
208
+ }
209
+
210
+ /**
211
+ * Last N lines of a service log. `fromOffset` limits it to output written
212
+ * after a given byte position — pass a start record's `logOffset` to show only
213
+ * the run that just failed, rather than the whole file's history.
214
+ */
215
+ export function tailLog(name, lines = 20, fromOffset = 0) {
216
+ try {
217
+ let text = fs.readFileSync(logFile(name), "utf-8");
218
+ if (fromOffset > 0) text = Buffer.from(text, "utf-8").subarray(fromOffset).toString("utf-8");
219
+ return text.split(/\r?\n/).filter(Boolean).slice(-lines);
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ export function uptime(startedAt) {
226
+ const ms = Date.now() - new Date(startedAt).getTime();
227
+ if (!Number.isFinite(ms) || ms < 0) return "?";
228
+ const s = Math.floor(ms / 1000);
229
+ if (s < 60) return `${s}s`;
230
+ const m = Math.floor(s / 60);
231
+ if (m < 60) return `${m}m`;
232
+ const h = Math.floor(m / 60);
233
+ if (h < 24) return `${h}h ${m % 60}m`;
234
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
235
+ }
package/src/lib/env.js ADDED
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import { envPath, ensureOuroDir } from "./paths.js";
3
+
4
+ // `.ouro/.env` — secrets for the background services.
5
+ //
6
+ // `ouro start` detaches the daemon from the shell that launched it, so the
7
+ // daemon can't inherit a token you only exported in that shell: close the
8
+ // terminal, or reboot, and `ouro listen` comes back up with no token and dies.
9
+ // A file the daemon reads at startup is what makes 24/7 actually 24/7.
10
+ //
11
+ // Gitignored by `ouro init` (see .ouro/.gitignore) — it holds a bot token.
12
+
13
+ // Group 1 is the `export ` prefix, kept so a rewrite can put it back — this is
14
+ // somebody's hand-written file, not a generated one.
15
+ const LINE = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/;
16
+
17
+ function unquote(value) {
18
+ const v = value.trim();
19
+ if (v.length >= 2 && ((v[0] === '"' && v.at(-1) === '"') || (v[0] === "'" && v.at(-1) === "'"))) {
20
+ return v.slice(1, -1);
21
+ }
22
+ // An unquoted trailing `# comment` isn't part of the value.
23
+ return v.split(" #")[0].trim();
24
+ }
25
+
26
+ export function parseEnv(text) {
27
+ const out = {};
28
+ for (const line of String(text ?? "").split(/\r?\n/)) {
29
+ if (!line.trim() || line.trim().startsWith("#")) continue;
30
+ const match = line.match(LINE);
31
+ if (match) out[match[2]] = unquote(match[3]);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Loads `.ouro/.env` into process.env. A real shell export always wins — if
38
+ * you deliberately set a token for one invocation, a stale file shouldn't
39
+ * silently override it.
40
+ */
41
+ export function loadEnvFile() {
42
+ let text;
43
+ try {
44
+ text = fs.readFileSync(envPath(), "utf-8");
45
+ } catch {
46
+ return {}; // no file is the normal case, not an error
47
+ }
48
+
49
+ const vars = parseEnv(text);
50
+ for (const [key, value] of Object.entries(vars)) {
51
+ if (process.env[key] === undefined) process.env[key] = value;
52
+ }
53
+ return vars;
54
+ }
55
+
56
+ export function hasEnvFile() {
57
+ return fs.existsSync(envPath());
58
+ }
59
+
60
+ /** What's actually in the file, without touching process.env. */
61
+ export function readEnvVars() {
62
+ try {
63
+ return parseEnv(fs.readFileSync(envPath(), "utf-8"));
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ const HEADER = [
70
+ "# ouro secrets — read by the background services at startup.",
71
+ "# Gitignored (.ouro/.gitignore). Written by the dashboard's Settings screen,",
72
+ "# and safe to hand-edit: comments and unknown keys survive a save.",
73
+ "",
74
+ ];
75
+
76
+ // Bare words don't need quoting; anything with whitespace, a `#`, or a quote
77
+ // would come back wrong through unquote(), so it goes through JSON.
78
+ function serialize(value) {
79
+ const v = String(value);
80
+ return /^[A-Za-z0-9_\-.:/@+=]*$/.test(v) ? v : JSON.stringify(v);
81
+ }
82
+
83
+ /**
84
+ * Merges vars into `.ouro/.env`. A `null` value deletes the key.
85
+ *
86
+ * Rewrites lines in place rather than regenerating the file, because this file
87
+ * has two authors: the dashboard and a human with an editor. Comments, order,
88
+ * `export ` prefixes and keys ouro knows nothing about all outlive a save.
89
+ */
90
+ export function writeEnvVars(patch) {
91
+ ensureOuroDir();
92
+
93
+ let existing = null;
94
+ try {
95
+ existing = fs.readFileSync(envPath(), "utf-8");
96
+ } catch {
97
+ // No file yet — the header is what tells whoever opens it later what it is.
98
+ }
99
+
100
+ const pending = new Map(Object.entries(patch));
101
+ const next = [];
102
+
103
+ for (const line of existing === null ? HEADER : existing.split(/\r?\n/)) {
104
+ const match = line.trim().startsWith("#") ? null : line.match(LINE);
105
+ const key = match?.[2];
106
+ if (!key || !pending.has(key)) {
107
+ next.push(line);
108
+ continue;
109
+ }
110
+ const value = pending.get(key);
111
+ pending.delete(key);
112
+ if (value !== null) next.push(`${match[1]}${key}=${serialize(value)}`);
113
+ }
114
+
115
+ for (const [key, value] of pending) {
116
+ if (value !== null) next.push(`${key}=${serialize(value)}`);
117
+ }
118
+
119
+ while (next.length && !next.at(-1).trim()) next.pop(); // no growing tail of blanks
120
+
121
+ // 0600 applies on create only, which is the case that matters: a token
122
+ // shouldn't land world-readable in the first place.
123
+ fs.writeFileSync(envPath(), next.join("\n") + "\n", { mode: 0o600 });
124
+
125
+ // The file and this process's env have to move together: everything here
126
+ // reads tokens from process.env, and a service spawned after this call
127
+ // inherits it (see lib/daemon.js). Unlike loadEnvFile, an explicit save wins
128
+ // over a shell export — you just told us what the token is.
129
+ for (const [key, value] of Object.entries(patch)) {
130
+ if (value === null) delete process.env[key];
131
+ else process.env[key] = String(value);
132
+ }
133
+
134
+ return readEnvVars();
135
+ }