saros-proxy 0.10.2 → 0.11.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 +175 -29
- package/dist/cli/daemon.d.ts +38 -6
- package/dist/cli/daemon.js +524 -109
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/dsh-config.js +34 -9
- package/dist/cli/dsh-config.js.map +1 -1
- package/dist/cli/help.js +3 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/key-state.d.ts +21 -0
- package/dist/cli/key-state.js +57 -0
- package/dist/cli/key-state.js.map +1 -0
- package/dist/cli/keys.d.ts +28 -0
- package/dist/cli/keys.js +185 -0
- package/dist/cli/keys.js.map +1 -0
- package/dist/cli/overview.js +6 -1
- package/dist/cli/overview.js.map +1 -1
- package/dist/cli/secret.d.ts +31 -0
- package/dist/cli/secret.js +89 -0
- package/dist/cli/secret.js.map +1 -0
- package/dist/cli/usage.js +7 -0
- package/dist/cli/usage.js.map +1 -1
- package/dist/config.d.ts +15 -1
- package/dist/config.js +98 -25
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/debug-buffer.d.ts +58 -0
- package/dist/debug-buffer.js +42 -0
- package/dist/debug-buffer.js.map +1 -0
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/log-redact.d.ts +18 -0
- package/dist/log-redact.js +41 -0
- package/dist/log-redact.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +30 -0
- package/dist/logger.js.map +1 -1
- package/dist/providers/infer.d.ts +11 -0
- package/dist/providers/infer.js.map +1 -1
- package/dist/proxy-logic.d.ts +74 -3
- package/dist/proxy-logic.js +111 -9
- package/dist/proxy-logic.js.map +1 -1
- package/dist/proxy.d.ts +33 -1
- package/dist/proxy.js +328 -45
- package/dist/proxy.js.map +1 -1
- package/dist/secrets/boot.d.ts +33 -0
- package/dist/secrets/boot.js +65 -0
- package/dist/secrets/boot.js.map +1 -0
- package/dist/secrets/envelope.d.ts +44 -0
- package/dist/secrets/envelope.js +132 -0
- package/dist/secrets/envelope.js.map +1 -0
- package/dist/secrets/source.d.ts +130 -0
- package/dist/secrets/source.js +332 -0
- package/dist/secrets/source.js.map +1 -0
- package/dist/validation.d.ts +3 -3
- package/dist/validation.js +3 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/daemon.js
CHANGED
|
@@ -9,30 +9,70 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Daemon lifecycle:
|
|
11
11
|
* start → refuses if the port is already serving, then spawns detached
|
|
12
|
-
* `node dist/index.js` process, saves PID,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* `node dist/index.js` process, saves PID, appends child stdout
|
|
13
|
+
* and stderr to daemon.log
|
|
14
|
+
* stop → kills the process (taskkill /T /F on Windows) and removes the
|
|
15
|
+
* PID file only once the process is confirmed gone; exits 1 when
|
|
16
|
+
* something still serves the port
|
|
17
|
+
* restart → kills running instance (if any), waits for it to disappear,
|
|
18
|
+
* then starts fresh
|
|
15
19
|
* status → checks PID file, falls back to an os-agnostic /health port
|
|
16
20
|
* probe (instances started outside the daemon have no PID file),
|
|
17
21
|
* and prints the enabled harnesses
|
|
18
22
|
*/
|
|
19
23
|
import { spawn, execFileSync } from 'node:child_process';
|
|
20
24
|
import chalk from 'chalk';
|
|
21
|
-
import { existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, copyFileSync,
|
|
22
|
-
import { resolve as pathResolve, join as pathJoin } from 'node:path';
|
|
25
|
+
import { existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, copyFileSync, statSync, renameSync, openSync, closeSync, chmodSync, readSync, fstatSync, realpathSync } from 'node:fs';
|
|
26
|
+
import { resolve as pathResolve, join as pathJoin, relative as pathRelative, isAbsolute as pathIsAbsolute, dirname as pathDirname, basename as pathBasename, sep as pathSep, } from 'node:path';
|
|
23
27
|
import { homedir } from 'node:os';
|
|
24
28
|
import { fileURLToPath } from 'node:url';
|
|
25
29
|
import { getModelsJsonPath } from './opencode-config.js';
|
|
30
|
+
import { formatKeyBlock } from './key-state.js';
|
|
26
31
|
import { checkForUpdate } from './update-check.js';
|
|
27
|
-
import { loadConfig } from '../config.js';
|
|
32
|
+
import { loadConfig, getSarosDir } from '../config.js';
|
|
28
33
|
import { DAEMON_SYNC_TIMEOUT_MS } from '../constants.js';
|
|
29
34
|
import { syncModelsInAllHarnesses, readHarnessSettings } from './harness-sync.js';
|
|
30
35
|
// ---------------------------------------------------------------------------
|
|
31
36
|
// Constants
|
|
32
37
|
// ---------------------------------------------------------------------------
|
|
33
|
-
|
|
38
|
+
/**
|
|
39
|
+
* State dir: same resolver as config.yaml, so on Windows everything (config,
|
|
40
|
+
* pid, log) lives under %LOCALAPPDATA%\saros — the pid file used to sit in
|
|
41
|
+
* ~/.config/saros there while the config sat elsewhere.
|
|
42
|
+
*/
|
|
43
|
+
function resolveStateDir() {
|
|
44
|
+
try {
|
|
45
|
+
return getSarosDir();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// No determinable home (headless test env): the legacy shape keeps the
|
|
49
|
+
// CLI importable instead of failing before it can report anything.
|
|
50
|
+
return pathJoin(homedir(), '.config', 'saros');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const PID_DIR = resolveStateDir();
|
|
34
54
|
const PID_FILE = pathJoin(PID_DIR, 'daemon.pid');
|
|
35
55
|
const DAEMON_LOG = pathJoin(PID_DIR, 'daemon.log');
|
|
56
|
+
/**
|
|
57
|
+
* Pre-0.11 location. Transition path, and OPT-IN: this path sits in the
|
|
58
|
+
* user's real home, so an isolated run (a test with a temp XDG_CONFIG_HOME but
|
|
59
|
+
* the real HOME) would otherwise act on the developer's live daemon — it did,
|
|
60
|
+
* killing a running proxy from a test. Reading, migrating and deleting it all
|
|
61
|
+
* require SAROS_ALLOW_LEGACY_PID=1; without the flag the file is inert.
|
|
62
|
+
* null when the state dir already *is* that location (POSIX default).
|
|
63
|
+
*/
|
|
64
|
+
const LEGACY_PID_FILE = resolveLegacyPidFile();
|
|
65
|
+
/** Legacy PID handling is explicit operator intent, never a silent fallback. */
|
|
66
|
+
function legacyPidEnabled() {
|
|
67
|
+
return process.env.SAROS_ALLOW_LEGACY_PID === '1';
|
|
68
|
+
}
|
|
69
|
+
function resolveLegacyPidFile() {
|
|
70
|
+
const legacy = pathJoin(homedir(), '.config', 'saros', 'daemon.pid');
|
|
71
|
+
return pathResolve(legacy) === pathResolve(PID_FILE) ? null : legacy;
|
|
72
|
+
}
|
|
73
|
+
/** daemon.log is rotated at start: 10 MB per file, 5 kept. */
|
|
74
|
+
const LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
75
|
+
const LOG_KEEP = 5;
|
|
36
76
|
const PACKAGE_ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
37
77
|
const ENTRY_POINT = pathResolve(PACKAGE_ROOT, 'dist', 'index.js');
|
|
38
78
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +115,94 @@ function getEnv(name) {
|
|
|
75
115
|
return process.env[name] ?? readWindowsUserEnv(name);
|
|
76
116
|
}
|
|
77
117
|
// ---------------------------------------------------------------------------
|
|
118
|
+
// Test-run guard
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* test/setup-env.ts publishes the account's real home here before any src
|
|
122
|
+
* module is evaluated. `userInfo().homedir` is the one value a suite cannot
|
|
123
|
+
* fake, which makes the guard below independent of environment isolation.
|
|
124
|
+
*/
|
|
125
|
+
const REAL_HOME_GLOBAL = '__SAROS_REAL_HOME__';
|
|
126
|
+
/** A vitest / `NODE_ENV=test` run: the real installation's handles are not ours. */
|
|
127
|
+
const TEST_RUN = process.env.NODE_ENV === 'test' || process.env.VITEST !== undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Refuse a PID path inside a real installation's state dir while under test.
|
|
130
|
+
*
|
|
131
|
+
* #104: a suite with a temporary config dir but the real HOME read
|
|
132
|
+
* `~/.config/saros/daemon.pid`, and `stop` killed the live daemon. The env
|
|
133
|
+
* isolation in test/setup-env.ts covers the normal path, but any suite that
|
|
134
|
+
* re-registers `node:os` (`test/config.test.ts` did) or drops
|
|
135
|
+
* XDG_CONFIG_HOME resolves the real home again. This makes the incident
|
|
136
|
+
* structural: under test, a file in the real installation's state dir is
|
|
137
|
+
* never read, never removed and never written, and a PID only reaches
|
|
138
|
+
* killProcess through this read.
|
|
139
|
+
*
|
|
140
|
+
* The two dirs are the ones a real installation can use (`~/.config/saros`,
|
|
141
|
+
* `~/AppData/Local/saros`), not "anything under $HOME": temp dirs can live
|
|
142
|
+
* under the home (TMPDIR=/home/<user>/.cache/...), and refusing those would
|
|
143
|
+
* only break the isolation it is meant to prove.
|
|
144
|
+
*/
|
|
145
|
+
function pidPathRefused(file) {
|
|
146
|
+
if (!TEST_RUN)
|
|
147
|
+
return false;
|
|
148
|
+
const realHome = globalThis[REAL_HOME_GLOBAL];
|
|
149
|
+
if (typeof realHome !== 'string' || realHome === '')
|
|
150
|
+
return false;
|
|
151
|
+
// Compare real locations, not spellings: an XDG_CONFIG_HOME symlinked into
|
|
152
|
+
// the real install passed the lexical check and the test happily killed the
|
|
153
|
+
// live daemon.
|
|
154
|
+
const target = canonicalPath(file);
|
|
155
|
+
const realDirs = [
|
|
156
|
+
pathJoin(realHome, '.config', 'saros'),
|
|
157
|
+
pathJoin(realHome, 'AppData', 'Local', 'saros'),
|
|
158
|
+
];
|
|
159
|
+
return realDirs.some((dir) => isBelow(canonicalPath(dir), target));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `file` with symlinks resolved, or the longest existing prefix of it with
|
|
163
|
+
* the missing tail kept lexical. A pid or log file usually does not exist
|
|
164
|
+
* yet, and `realpathSync` throws on a missing file — but its directory does,
|
|
165
|
+
* and that is what a symlinked state dir hides behind.
|
|
166
|
+
*/
|
|
167
|
+
function canonicalPath(file) {
|
|
168
|
+
let current = pathResolve(file);
|
|
169
|
+
let rest = '';
|
|
170
|
+
for (;;) {
|
|
171
|
+
try {
|
|
172
|
+
const real = realpathSync(current);
|
|
173
|
+
return rest === '' ? real : pathJoin(real, rest);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
const parent = pathDirname(current);
|
|
177
|
+
// The root always exists: a failure there means realpathSync refused
|
|
178
|
+
// for another reason (EACCES, ELOOP), so keep the lexical path instead
|
|
179
|
+
// of walking in circles.
|
|
180
|
+
if (parent === current)
|
|
181
|
+
return pathResolve(file);
|
|
182
|
+
rest = rest === '' ? pathBasename(current) : pathJoin(pathBasename(current), rest);
|
|
183
|
+
current = parent;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Is `target` strictly below `dir`? Both are absolute, canonical paths. */
|
|
188
|
+
function isBelow(dir, target) {
|
|
189
|
+
const rel = pathRelative(dir, target);
|
|
190
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${pathSep}`) && !pathIsAbsolute(rel);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The one gate for every path this module touches — pid file, daemon.log,
|
|
194
|
+
* models.json, and the legacy pid file on the opt-in upgrade path. Read,
|
|
195
|
+
* remove and write all pass through here; returning false means the caller
|
|
196
|
+
* must leave the path alone and take its no-op branch (null, silent return or
|
|
197
|
+
* exit 1). Inert outside a test run, where pidPathRefused is always false.
|
|
198
|
+
*/
|
|
199
|
+
function assertStateDir(file, action) {
|
|
200
|
+
if (!pidPathRefused(file))
|
|
201
|
+
return true;
|
|
202
|
+
console.error(chalk.red(`✗ Refusing to ${action} ${file}: a test run must never touch the real home. Point HOME and XDG_CONFIG_HOME at a temp dir.`));
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
78
206
|
// PID file management
|
|
79
207
|
// ---------------------------------------------------------------------------
|
|
80
208
|
function ensurePidDir() {
|
|
@@ -82,32 +210,183 @@ function ensurePidDir() {
|
|
|
82
210
|
mkdirSync(PID_DIR, { recursive: true });
|
|
83
211
|
}
|
|
84
212
|
}
|
|
85
|
-
|
|
213
|
+
/** PID digits, or null when the file is missing/empty/unusable. */
|
|
214
|
+
function parsePid(raw) {
|
|
215
|
+
if (raw === null)
|
|
216
|
+
return null;
|
|
217
|
+
const pid = Number(raw);
|
|
218
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
219
|
+
}
|
|
220
|
+
function readPidFile(file) {
|
|
221
|
+
if (file === null)
|
|
222
|
+
return null;
|
|
223
|
+
if (!assertStateDir(file, 'read'))
|
|
224
|
+
return null;
|
|
86
225
|
try {
|
|
87
|
-
return readFileSync(
|
|
226
|
+
return readFileSync(file, 'utf-8').trim() || null;
|
|
88
227
|
}
|
|
89
228
|
catch {
|
|
90
229
|
return null;
|
|
91
230
|
}
|
|
92
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* The running PID: the current state dir first. The pre-0.11 path is consulted
|
|
234
|
+
* only when the operator sets SAROS_ALLOW_LEGACY_PID=1 — it lives in the real
|
|
235
|
+
* home, so treating it as a default fallback made isolated runs kill a live
|
|
236
|
+
* daemon. With the flag on, a live legacy PID is migrated into the state dir.
|
|
237
|
+
*/
|
|
238
|
+
function readPid() {
|
|
239
|
+
const current = readPidFile(PID_FILE);
|
|
240
|
+
if (current !== null)
|
|
241
|
+
return { pid: current, file: PID_FILE };
|
|
242
|
+
if (LEGACY_PID_FILE === null || !legacyPidEnabled())
|
|
243
|
+
return null;
|
|
244
|
+
const legacy = readPidFile(LEGACY_PID_FILE);
|
|
245
|
+
if (legacy === null)
|
|
246
|
+
return null;
|
|
247
|
+
return { pid: legacy, file: migrateLegacyPid(legacy, LEGACY_PID_FILE) };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Move an opt-in legacy PID into the state dir. Returns the file that holds
|
|
251
|
+
* the handle afterwards: a read-only state dir leaves it where it was.
|
|
252
|
+
*/
|
|
253
|
+
function migrateLegacyPid(pid, legacyFile) {
|
|
254
|
+
if (!legacyPidEnabled())
|
|
255
|
+
return legacyFile;
|
|
256
|
+
if (!assertStateDir(PID_FILE, 'write') || !assertStateDir(legacyFile, 'remove')) {
|
|
257
|
+
return legacyFile;
|
|
258
|
+
}
|
|
259
|
+
const pidNum = parsePid(pid);
|
|
260
|
+
if (pidNum === null || !isProcessAlive(pidNum))
|
|
261
|
+
return legacyFile;
|
|
262
|
+
try {
|
|
263
|
+
ensurePidDir();
|
|
264
|
+
writeFileSync(PID_FILE, pid, 'utf-8');
|
|
265
|
+
rmSync(legacyFile, { force: true });
|
|
266
|
+
return PID_FILE;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return legacyFile;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
93
272
|
function writePid(pid) {
|
|
273
|
+
if (!assertStateDir(PID_FILE, 'write'))
|
|
274
|
+
return;
|
|
94
275
|
ensurePidDir();
|
|
95
276
|
writeFileSync(PID_FILE, String(pid), 'utf-8');
|
|
96
277
|
}
|
|
97
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Remove one PID file: the one whose PID this process acted on. Removing both
|
|
280
|
+
* used to drop a live legacy instance's handle whenever the current file was
|
|
281
|
+
* the stale one.
|
|
282
|
+
*/
|
|
283
|
+
function removePidFile(file) {
|
|
284
|
+
if (file === null || !assertStateDir(file, 'remove'))
|
|
285
|
+
return;
|
|
98
286
|
try {
|
|
99
|
-
rmSync(
|
|
287
|
+
rmSync(file, { force: true });
|
|
100
288
|
}
|
|
101
289
|
catch {
|
|
102
290
|
// ignore
|
|
103
291
|
}
|
|
104
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Rotate daemon.log at start when it grew past LOG_MAX_BYTES:
|
|
295
|
+
* daemon.log.5 is dropped, .4→.5 … .1→.2, and daemon.log becomes .1.
|
|
296
|
+
*
|
|
297
|
+
* Rotation at boot keeps the logging path dependency-free. A local proxy
|
|
298
|
+
* restarts far more often than it writes 10 MB, so mid-run rotation buys
|
|
299
|
+
* nothing but a runtime dependency.
|
|
300
|
+
*/
|
|
301
|
+
function rotateDaemonLog() {
|
|
302
|
+
if (!assertStateDir(DAEMON_LOG, 'write'))
|
|
303
|
+
return;
|
|
304
|
+
try {
|
|
305
|
+
if (!existsSync(DAEMON_LOG))
|
|
306
|
+
return;
|
|
307
|
+
if (statSync(DAEMON_LOG).size < LOG_MAX_BYTES)
|
|
308
|
+
return;
|
|
309
|
+
rmSync(`${DAEMON_LOG}.${LOG_KEEP}`, { force: true });
|
|
310
|
+
// Shift newest-first: .4→.5 … .1→.2, then the live log becomes .1. The
|
|
311
|
+
// loop used to stop at i=4, so .4→.5 never happened and the newest
|
|
312
|
+
// generation was silently dropped while .5 disappeared.
|
|
313
|
+
for (let i = LOG_KEEP; i >= 2; i--) {
|
|
314
|
+
const from = `${DAEMON_LOG}.${i - 1}`;
|
|
315
|
+
if (existsSync(from))
|
|
316
|
+
renameSync(from, `${DAEMON_LOG}.${i}`);
|
|
317
|
+
}
|
|
318
|
+
if (existsSync(DAEMON_LOG))
|
|
319
|
+
renameSync(DAEMON_LOG, `${DAEMON_LOG}.1`);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Diagnostics only — a failed rotation must never block startup.
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Open daemon.log for append (0600 — it carries upstream error bodies).
|
|
327
|
+
* Returns null when the file cannot be opened, so callers fall back to
|
|
328
|
+
* discarding output. Used for the child's stdout and stderr, which share this
|
|
329
|
+
* single file. Exported so the wiring can be asserted in tests.
|
|
330
|
+
*/
|
|
331
|
+
export function openDaemonLog() {
|
|
332
|
+
if (!assertStateDir(DAEMON_LOG, 'write'))
|
|
333
|
+
return null;
|
|
334
|
+
try {
|
|
335
|
+
ensurePidDir();
|
|
336
|
+
// mode only applies on create; an existing 0644 log from an older version
|
|
337
|
+
// keeps pino output world-readable unless we chmod it explicitly.
|
|
338
|
+
const fd = openSync(DAEMON_LOG, 'a', 0o600);
|
|
339
|
+
chmodSync(DAEMON_LOG, 0o600);
|
|
340
|
+
return fd;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Enough of the boot tail to hold a stack trace; diagnostics only. */
|
|
347
|
+
const LOG_TAIL_BYTES = 16 * 1024;
|
|
348
|
+
/** Current daemon.log size — the offset past which this boot's output starts. */
|
|
349
|
+
function logSize() {
|
|
350
|
+
try {
|
|
351
|
+
return statSync(DAEMON_LOG).size;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Read daemon.log from `offset` to the end, capped at LOG_TAIL_BYTES.
|
|
359
|
+
* The child's stdout and stderr are the same file (no pipe), so a startup
|
|
360
|
+
* failure has to be recovered from what this boot appended to the log.
|
|
361
|
+
*/
|
|
362
|
+
function readLogSince(offset) {
|
|
363
|
+
try {
|
|
364
|
+
const fd = openSync(DAEMON_LOG, 'r');
|
|
365
|
+
try {
|
|
366
|
+
const size = fstatSync(fd).size;
|
|
367
|
+
const start = Math.max(offset, size - LOG_TAIL_BYTES);
|
|
368
|
+
if (size <= start)
|
|
369
|
+
return '';
|
|
370
|
+
const buf = Buffer.alloc(size - start);
|
|
371
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
372
|
+
return buf.toString('utf-8');
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
closeSync(fd);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return '';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
105
382
|
/**
|
|
106
383
|
* Ensure models.json exists at ~/.config/saros/models.json.
|
|
107
384
|
* Always overwrites with the bundled copy on every daemon start.
|
|
108
385
|
*/
|
|
109
386
|
function ensureModelsJson() {
|
|
110
387
|
const modelsPath = getModelsJsonPath();
|
|
388
|
+
if (!assertStateDir(modelsPath, 'write'))
|
|
389
|
+
return;
|
|
111
390
|
const dir = pathJoin(homedir(), '.config', 'saros');
|
|
112
391
|
if (!existsSync(dir)) {
|
|
113
392
|
mkdirSync(dir, { recursive: true });
|
|
@@ -121,46 +400,75 @@ function ensureModelsJson() {
|
|
|
121
400
|
// ---------------------------------------------------------------------------
|
|
122
401
|
// Process management
|
|
123
402
|
// ---------------------------------------------------------------------------
|
|
124
|
-
/**
|
|
125
|
-
|
|
403
|
+
/**
|
|
404
|
+
* Check if a process is alive by PID.
|
|
405
|
+
*
|
|
406
|
+
* `process.kill(pid, 0)` is the portable liveness probe: Node maps it to
|
|
407
|
+
* OpenProcess on Windows, so a dead PID fails with ESRCH and an EPERM/EACCES
|
|
408
|
+
* answer means "the PID exists, it is just not ours to signal" → alive.
|
|
409
|
+
*
|
|
410
|
+
* The previous win32 path ran `tasklist /FI "PID eq <pid>"`, which exits 0
|
|
411
|
+
* even when no task matches — every PID looked alive (#83).
|
|
412
|
+
*/
|
|
413
|
+
export function isProcessAlive(pid) {
|
|
414
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
415
|
+
return false;
|
|
126
416
|
try {
|
|
127
|
-
if (process.platform === 'win32') {
|
|
128
|
-
execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], {
|
|
129
|
-
windowsHide: true,
|
|
130
|
-
timeout: 5000,
|
|
131
|
-
stdio: 'pipe',
|
|
132
|
-
});
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
417
|
process.kill(pid, 0);
|
|
136
418
|
return true;
|
|
137
419
|
}
|
|
138
|
-
catch {
|
|
139
|
-
|
|
420
|
+
catch (err) {
|
|
421
|
+
const code = err.code;
|
|
422
|
+
return code === 'EPERM' || code === 'EACCES';
|
|
140
423
|
}
|
|
141
424
|
}
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
425
|
+
/** taskkill's exit code for "no process with that PID". */
|
|
426
|
+
const TASKKILL_NOT_FOUND = 128;
|
|
427
|
+
/** taskkill's generic failure code, kept only when it carries no diagnostic. */
|
|
428
|
+
const TASKKILL_GENERIC_FAILURE = 1;
|
|
429
|
+
/**
|
|
430
|
+
* Kill a process and all its children. Returns whether the target is gone.
|
|
431
|
+
*
|
|
432
|
+
* Output is piped (never inherited): taskkill's "not found" text used to land
|
|
433
|
+
* in the caller's console and PowerShell surfaced it as a NativeCommandError.
|
|
434
|
+
*/
|
|
435
|
+
export function killProcess(pid) {
|
|
436
|
+
try {
|
|
437
|
+
if (process.platform === 'win32') {
|
|
146
438
|
execFileSync('taskkill', ['/T', '/F', '/PID', String(pid)], {
|
|
147
439
|
windowsHide: true,
|
|
148
440
|
timeout: 10000,
|
|
441
|
+
stdio: 'pipe',
|
|
149
442
|
});
|
|
150
443
|
}
|
|
151
|
-
|
|
152
|
-
// Process may already be dead — ignore
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
try {
|
|
444
|
+
else {
|
|
157
445
|
process.kill(pid, 'SIGKILL');
|
|
158
446
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
// Dying between the liveness check and the kill is success, not failure.
|
|
451
|
+
return isAlreadyExited(err);
|
|
162
452
|
}
|
|
163
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Did the target already exit? Decided by the exit code, never by the message:
|
|
456
|
+
* taskkill localises "not found" ("não foi encontrado", "no se encuentra"), so
|
|
457
|
+
* text matching only worked in English and Portuguese.
|
|
458
|
+
*
|
|
459
|
+
* A non-zero taskkill exit with no output is a wrapper that swallowed the
|
|
460
|
+
* reason; "Access is denied" and friends always write one, and keeping such a
|
|
461
|
+
* failure visible is the point of the boolean.
|
|
462
|
+
*/
|
|
463
|
+
function isAlreadyExited(err) {
|
|
464
|
+
const failure = err;
|
|
465
|
+
if (failure?.code === 'ESRCH')
|
|
466
|
+
return true;
|
|
467
|
+
if (failure?.status === TASKKILL_NOT_FOUND)
|
|
468
|
+
return true;
|
|
469
|
+
const output = `${failure?.stdout ?? ''}${failure?.stderr ?? ''}`.trim();
|
|
470
|
+
return failure?.status === TASKKILL_GENERIC_FAILURE && output === '';
|
|
471
|
+
}
|
|
164
472
|
// ---------------------------------------------------------------------------
|
|
165
473
|
// Port probing (os-agnostic — no platform-specific process tools)
|
|
166
474
|
// ---------------------------------------------------------------------------
|
|
@@ -198,33 +506,44 @@ function printHarnesses() {
|
|
|
198
506
|
const enabled = readHarnessSettings();
|
|
199
507
|
console.log(chalk.cyan(`Enabled harnesses: ${enabled.join(', ') || '(none)'}`));
|
|
200
508
|
}
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
// Internal helpers
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
509
|
/**
|
|
205
510
|
* Synchronously kill the running daemon if its PID file exists and the
|
|
206
511
|
* process is alive. Cleans up stale PID files. Does NOT call process.exit.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
512
|
+
*
|
|
513
|
+
* The PID file survives a kill that failed: dropping the handle left the next
|
|
514
|
+
* `stop` reporting "no running proxy daemon" (exit 0) for a proxy that was
|
|
515
|
+
* still serving, and `restart` then died on the busy port.
|
|
209
516
|
*/
|
|
210
517
|
function killRunningDaemon() {
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
return { pid: null, wasRunning: false, wasStale: false };
|
|
214
|
-
|
|
518
|
+
const record = readPid();
|
|
519
|
+
if (record === null) {
|
|
520
|
+
return { pid: null, pidFile: null, wasRunning: false, wasStale: false, killed: false };
|
|
521
|
+
}
|
|
522
|
+
const pidNum = parsePid(record.pid);
|
|
523
|
+
if (pidNum === null) {
|
|
524
|
+
// Garbage in the file — nothing to kill, but the file is not evidence of
|
|
525
|
+
// a running daemon either.
|
|
526
|
+
removePidFile(record.file);
|
|
527
|
+
return { pid: null, pidFile: null, wasRunning: false, wasStale: false, killed: false };
|
|
528
|
+
}
|
|
529
|
+
const pid = String(pidNum);
|
|
215
530
|
if (!isProcessAlive(pidNum)) {
|
|
216
531
|
// Stale PID file — clean it up
|
|
217
|
-
|
|
218
|
-
return { pid, wasRunning: false, wasStale: true };
|
|
532
|
+
removePidFile(record.file);
|
|
533
|
+
return { pid, pidFile: record.file, wasRunning: false, wasStale: true, killed: true };
|
|
219
534
|
}
|
|
220
|
-
killProcess(pidNum);
|
|
221
|
-
|
|
222
|
-
return { pid, wasRunning: true, wasStale: false };
|
|
535
|
+
const killed = killProcess(pidNum) || !isProcessAlive(pidNum);
|
|
536
|
+
return { pid, pidFile: record.file, wasRunning: true, wasStale: false, killed };
|
|
223
537
|
}
|
|
224
538
|
// ---------------------------------------------------------------------------
|
|
225
539
|
// Public API
|
|
226
540
|
// ---------------------------------------------------------------------------
|
|
227
541
|
export async function daemonStart(port, configPath) {
|
|
542
|
+
// Before the first directory is created: under test a state dir in the real
|
|
543
|
+
// installation is not ours to populate — not the pid, not daemon.log, not
|
|
544
|
+
// models.json.
|
|
545
|
+
if (!assertStateDir(PID_FILE, 'write'))
|
|
546
|
+
process.exit(1);
|
|
228
547
|
ensurePidDir();
|
|
229
548
|
const effectivePort = port ?? getConfigPort(configPath);
|
|
230
549
|
// Refuse to start when the port is already serving: a second instance
|
|
@@ -244,29 +563,47 @@ export async function daemonStart(port, configPath) {
|
|
|
244
563
|
args.push('--config', configPath);
|
|
245
564
|
// Spawn detached child (no console window on Windows)
|
|
246
565
|
// Read env vars from registry if not in process.env (Windows User-scope fix)
|
|
247
|
-
const env = {
|
|
566
|
+
const env = {
|
|
567
|
+
...process.env,
|
|
568
|
+
NODE_ENV: 'production',
|
|
569
|
+
// The child has no terminal: never try the TTY passphrase prompt there.
|
|
570
|
+
SAROS_DAEMON: '1',
|
|
571
|
+
};
|
|
248
572
|
const encKey = getEnv('OPENCODE_GO_ENCRYPTION_KEY');
|
|
249
573
|
if (encKey)
|
|
250
574
|
env.OPENCODE_GO_ENCRYPTION_KEY = encKey;
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
575
|
+
// Child stdout carries pino, stderr carries node's uncaught-exception
|
|
576
|
+
// reporter and startup errors. Both are the same daemon.log descriptor and
|
|
577
|
+
// no pipe is involved: the parent exits after the models sync, and a pipe's
|
|
578
|
+
// missing reader used to kill the serve process with EPIPE on the next
|
|
579
|
+
// stderr write (#99) — a crash trace could be the thing that killed the proxy.
|
|
580
|
+
rotateDaemonLog();
|
|
581
|
+
// A synchronous append descriptor, not createWriteStream: a stream opens
|
|
582
|
+
// asynchronously and its handle outlived startup verification, so cleanup
|
|
583
|
+
// that removes the directory raced with the pending open (ENOTEMPTY) and a
|
|
584
|
+
// restart left a writer on the old log. openDaemonLog chmods to 0600 (the
|
|
585
|
+
// log carries upstream error bodies) and returns null when the file cannot
|
|
586
|
+
// be opened — logging is diagnostic only.
|
|
587
|
+
const logFd = openDaemonLog();
|
|
588
|
+
// Everything past this offset belongs to this boot; earlier boots must not
|
|
589
|
+
// be able to explain a failure.
|
|
590
|
+
const bootOffset = logSize();
|
|
259
591
|
const child = spawn('node', args, {
|
|
260
592
|
cwd: PACKAGE_ROOT,
|
|
261
|
-
stdio: ['ignore', 'ignore', '
|
|
593
|
+
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
|
|
262
594
|
detached: true,
|
|
263
595
|
windowsHide: true,
|
|
264
596
|
env,
|
|
265
597
|
});
|
|
266
|
-
child
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
598
|
+
// The child holds its own duplicates of the descriptor.
|
|
599
|
+
if (logFd !== null) {
|
|
600
|
+
try {
|
|
601
|
+
closeSync(logFd);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// ignore
|
|
605
|
+
}
|
|
606
|
+
}
|
|
270
607
|
// Allow parent to exit independently while child continues
|
|
271
608
|
child.unref();
|
|
272
609
|
const pid = child.pid;
|
|
@@ -292,71 +629,137 @@ export async function daemonStart(port, configPath) {
|
|
|
292
629
|
process.exit(0);
|
|
293
630
|
}
|
|
294
631
|
else {
|
|
295
|
-
//
|
|
296
|
-
|
|
632
|
+
// The child had no pipe to capture: recover its failure from whatever
|
|
633
|
+
// this boot appended to daemon.log (stdout and stderr are the same file).
|
|
634
|
+
const tail = readLogSince(bootOffset);
|
|
635
|
+
if (tail.includes('EADDRINUSE')) {
|
|
297
636
|
console.error(chalk.red(`✗ Port ${effectivePort} is already in use. Choose a different port (\`--port <port>\`), or stop the running proxy first.`));
|
|
298
637
|
}
|
|
299
|
-
else if (
|
|
638
|
+
else if (tail.includes('No valid API keys')) {
|
|
300
639
|
console.error(chalk.red('✗ No valid API keys found. Ensure keys start with "sk-" and are at least 20 characters.'));
|
|
301
640
|
}
|
|
302
|
-
else if (
|
|
303
|
-
const
|
|
641
|
+
else if (tail.trim()) {
|
|
642
|
+
const lines = tail.trim().split('\n').slice(-6).join('\n');
|
|
304
643
|
console.error(chalk.red('✗ Proxy exited shortly after starting. Last output:'));
|
|
305
|
-
console.error(chalk.dim(
|
|
644
|
+
console.error(chalk.dim(lines));
|
|
306
645
|
}
|
|
307
646
|
else {
|
|
308
647
|
console.error(chalk.red('✗ Proxy exited shortly after starting. Check your config.'));
|
|
309
648
|
}
|
|
310
|
-
|
|
649
|
+
removePidFile(PID_FILE);
|
|
311
650
|
process.exit(1);
|
|
312
651
|
}
|
|
313
652
|
}, 2000);
|
|
314
653
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
654
|
+
/** How long `stop` waits for the killed process to disappear. */
|
|
655
|
+
const STOP_VERIFY_MS = 2000;
|
|
656
|
+
/** The pre-0.11 PID file the operator has not opted into yet (stat only). */
|
|
657
|
+
function legacyPidFileToAdopt() {
|
|
658
|
+
if (LEGACY_PID_FILE === null || legacyPidEnabled())
|
|
659
|
+
return null;
|
|
660
|
+
return existsSync(LEGACY_PID_FILE) ? LEGACY_PID_FILE : null;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Wait for a kill to land, then remove the PID file and run `onGone` — or
|
|
664
|
+
* report the failure and exit 1. The liveness re-check is the only honest
|
|
665
|
+
* answer: `killed` says the signal was accepted, not that the process died.
|
|
666
|
+
* A refused kill is checked at once, since waiting cannot change it.
|
|
667
|
+
*/
|
|
668
|
+
function awaitDaemonGone(result, onGone) {
|
|
669
|
+
const pidNum = Number(result.pid);
|
|
670
|
+
const check = () => {
|
|
329
671
|
if (isProcessAlive(pidNum)) {
|
|
330
|
-
|
|
672
|
+
const hint = process.platform === 'win32'
|
|
673
|
+
? `taskkill /T /F /PID ${result.pid}`
|
|
674
|
+
: `kill -9 ${result.pid}`;
|
|
675
|
+
console.error(chalk.red(`✗ Failed to stop daemon (PID ${result.pid}). Try: ${hint}`));
|
|
331
676
|
process.exit(1);
|
|
677
|
+
return;
|
|
332
678
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
679
|
+
removePidFile(result.pidFile);
|
|
680
|
+
onGone();
|
|
681
|
+
};
|
|
682
|
+
if (result.killed)
|
|
683
|
+
setTimeout(check, STOP_VERIFY_MS);
|
|
684
|
+
else
|
|
685
|
+
check();
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Nothing to stop in the state dir. That is not the whole truth: a 0.10.x
|
|
689
|
+
* installation keeps its PID under `~/.config/saros`, and an instance started
|
|
690
|
+
* outside the daemon never writes a handle at all, so "no pid file" used to
|
|
691
|
+
* print "no daemon" and exit 0 while a proxy was still serving. Probe the
|
|
692
|
+
* configured port: when /health answers, report what is serving — naming the
|
|
693
|
+
* legacy file and the flag when one exists — and exit non-zero.
|
|
694
|
+
*
|
|
695
|
+
* `alreadyReported` is for a caller that just announced a stale handle: the
|
|
696
|
+
* header is redundant, but the port verdict is not.
|
|
697
|
+
*/
|
|
698
|
+
async function reportNothingToStop(alreadyReported = false) {
|
|
699
|
+
const legacy = legacyPidFileToAdopt();
|
|
700
|
+
if (legacy === null) {
|
|
701
|
+
if (!alreadyReported)
|
|
702
|
+
console.log(chalk.yellow('No running proxy daemon found.'));
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
if (!alreadyReported) {
|
|
706
|
+
console.log(chalk.yellow(`No running proxy daemon found in ${PID_DIR}.`));
|
|
336
707
|
}
|
|
337
|
-
|
|
708
|
+
console.log(chalk.yellow(`A pre-0.11 PID file exists at ${legacy}.`));
|
|
709
|
+
console.log(chalk.cyan('Re-run with SAROS_ALLOW_LEGACY_PID=1 to let this command read and stop it.'));
|
|
710
|
+
}
|
|
711
|
+
const port = getConfigPort();
|
|
712
|
+
const probe = await probeProxyHealth(port);
|
|
713
|
+
if (!probe.ok)
|
|
714
|
+
process.exit(0);
|
|
715
|
+
const detail = legacy === null
|
|
716
|
+
? 'it was started outside `saros-proxy start`'
|
|
717
|
+
: 'the pre-0.11 instance was not stopped';
|
|
718
|
+
console.error(chalk.red(`✗ Something is still serving on port ${port} — ${detail}.`));
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
721
|
+
export async function daemonStop() {
|
|
722
|
+
const result = killRunningDaemon();
|
|
723
|
+
if (result.pid === null) {
|
|
724
|
+
await reportNothingToStop();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (result.wasStale) {
|
|
728
|
+
console.log(chalk.yellow(`Daemon process (PID ${result.pid}) is not running. Cleaning up PID file.`));
|
|
729
|
+
// The stale handle was removed; something can still serve — a live legacy
|
|
730
|
+
// instance, or one started outside the daemon. Exiting 0 here hid that.
|
|
731
|
+
await reportNothingToStop(true);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
awaitDaemonGone(result, () => {
|
|
735
|
+
console.log(chalk.green(`✓ Proxy (PID ${result.pid}) stopped.`));
|
|
736
|
+
process.exit(0);
|
|
737
|
+
});
|
|
338
738
|
}
|
|
339
739
|
/**
|
|
340
|
-
* Restart the proxy daemon: stop the running instance (if any), wait
|
|
341
|
-
*
|
|
740
|
+
* Restart the proxy daemon: stop the running instance (if any), wait for it
|
|
741
|
+
* to actually disappear and the port to release, then start a fresh instance.
|
|
342
742
|
* Reuses daemonStart which owns its own exit path via setTimeout.
|
|
743
|
+
*
|
|
744
|
+
* "Restarting: stopped daemon" used to print before the kill landed, so a
|
|
745
|
+
* failed stop was followed by a start that died on the busy port.
|
|
343
746
|
*/
|
|
344
747
|
export function daemonRestart(port, configPath) {
|
|
345
|
-
const
|
|
346
|
-
if (wasRunning && pid) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
748
|
+
const result = killRunningDaemon();
|
|
749
|
+
if (result.wasRunning && result.pid) {
|
|
750
|
+
awaitDaemonGone(result, () => {
|
|
751
|
+
console.log(chalk.dim(`Restarting: stopped daemon (PID ${result.pid}), starting new instance...`));
|
|
752
|
+
daemonStart(port, configPath);
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (result.wasStale && result.pid) {
|
|
757
|
+
console.log(chalk.dim(`Stale PID file (PID ${result.pid}) cleaned up. Starting new instance...`));
|
|
350
758
|
}
|
|
351
759
|
else {
|
|
352
|
-
|
|
353
|
-
console.log(chalk.dim(`Stale PID file (PID ${pid}) cleaned up. Starting new instance...`));
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
console.log(chalk.dim('No running daemon found, starting new instance...'));
|
|
357
|
-
}
|
|
358
|
-
daemonStart(port, configPath);
|
|
760
|
+
console.log(chalk.dim('No running daemon found, starting new instance...'));
|
|
359
761
|
}
|
|
762
|
+
daemonStart(port, configPath);
|
|
360
763
|
}
|
|
361
764
|
/**
|
|
362
765
|
* Resolve the daemon state os-agnostically: PID file first (cleaning up a
|
|
@@ -366,15 +769,16 @@ export function daemonRestart(port, configPath) {
|
|
|
366
769
|
*/
|
|
367
770
|
export async function getDaemonState() {
|
|
368
771
|
const port = getConfigPort();
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
const
|
|
772
|
+
const record = readPid();
|
|
773
|
+
const pidNum = parsePid(record?.pid ?? null);
|
|
774
|
+
const pidAlive = pidNum !== null && isProcessAlive(pidNum);
|
|
775
|
+
const stalePid = record !== null && !pidAlive;
|
|
372
776
|
if (stalePid)
|
|
373
|
-
|
|
777
|
+
removePidFile(record.file);
|
|
374
778
|
const probe = await probeProxyHealth(port);
|
|
375
779
|
return {
|
|
376
780
|
running: pidAlive || probe.ok,
|
|
377
|
-
pid: pidAlive ?
|
|
781
|
+
pid: pidAlive ? String(pidNum) : null,
|
|
378
782
|
port,
|
|
379
783
|
stalePid,
|
|
380
784
|
health: probe.ok ? probe.body : undefined,
|
|
@@ -382,6 +786,13 @@ export async function getDaemonState() {
|
|
|
382
786
|
}
|
|
383
787
|
export async function daemonStatus() {
|
|
384
788
|
const state = await getDaemonState();
|
|
789
|
+
// A 0.10.x installation keeps its PID under ~/.config/saros: without the
|
|
790
|
+
// flag `status` cannot see it, so say where it is and how to opt in.
|
|
791
|
+
const legacy = state.pid === null ? legacyPidFileToAdopt() : null;
|
|
792
|
+
if (legacy !== null) {
|
|
793
|
+
console.log(chalk.yellow(`A pre-0.11 PID file exists at ${legacy}.`));
|
|
794
|
+
console.log(chalk.cyan('Re-run with SAROS_ALLOW_LEGACY_PID=1 to let this command read it.'));
|
|
795
|
+
}
|
|
385
796
|
if (state.running) {
|
|
386
797
|
if (state.pid) {
|
|
387
798
|
console.log(chalk.green(`✓ Proxy is running (PID ${state.pid}) on port ${state.port}.`));
|
|
@@ -389,6 +800,10 @@ export async function daemonStatus() {
|
|
|
389
800
|
else {
|
|
390
801
|
console.log(chalk.green(`✓ Proxy is running on port ${state.port} (no PID file — started outside \`saros-proxy start\`).`));
|
|
391
802
|
}
|
|
803
|
+
// Per-key state from the daemon's own report: which key is out, why, and
|
|
804
|
+
// when it retries. Skipped silently when the payload has no keys block.
|
|
805
|
+
for (const line of formatKeyBlock(state.health))
|
|
806
|
+
console.log(line);
|
|
392
807
|
printHarnesses();
|
|
393
808
|
process.exit(0);
|
|
394
809
|
}
|