@plumpslabs/fennec-cli 1.13.4 → 1.13.6
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 +204 -95
- package/dist/index.js +2469 -686
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -129,14 +129,14 @@ var require_emoji_regex = __commonJS({
|
|
|
129
129
|
var require_string_width = __commonJS({
|
|
130
130
|
"../../node_modules/.pnpm/string-width@4.2.3/node_modules/string-width/index.js"(exports, module) {
|
|
131
131
|
"use strict";
|
|
132
|
-
var
|
|
132
|
+
var stripAnsi2 = require_strip_ansi();
|
|
133
133
|
var isFullwidthCodePoint = require_is_fullwidth_code_point();
|
|
134
134
|
var emojiRegex = require_emoji_regex();
|
|
135
135
|
var stringWidth = (string) => {
|
|
136
136
|
if (typeof string !== "string" || string.length === 0) {
|
|
137
137
|
return 0;
|
|
138
138
|
}
|
|
139
|
-
string =
|
|
139
|
+
string = stripAnsi2(string);
|
|
140
140
|
if (string.length === 0) {
|
|
141
141
|
return 0;
|
|
142
142
|
}
|
|
@@ -181,23 +181,23 @@ var require_utils = __commonJS({
|
|
|
181
181
|
function repeat(str, times) {
|
|
182
182
|
return Array(times + 1).join(str);
|
|
183
183
|
}
|
|
184
|
-
function
|
|
184
|
+
function pad2(str, len, pad3, dir) {
|
|
185
185
|
let length = strlen(str);
|
|
186
186
|
if (len + 1 >= length) {
|
|
187
187
|
let padlen = len - length;
|
|
188
188
|
switch (dir) {
|
|
189
189
|
case "right": {
|
|
190
|
-
str = repeat(
|
|
190
|
+
str = repeat(pad3, padlen) + str;
|
|
191
191
|
break;
|
|
192
192
|
}
|
|
193
193
|
case "center": {
|
|
194
194
|
let right = Math.ceil(padlen / 2);
|
|
195
195
|
let left = padlen - right;
|
|
196
|
-
str = repeat(
|
|
196
|
+
str = repeat(pad3, left) + str + repeat(pad3, right);
|
|
197
197
|
break;
|
|
198
198
|
}
|
|
199
199
|
default: {
|
|
200
|
-
str = str + repeat(
|
|
200
|
+
str = str + repeat(pad3, padlen);
|
|
201
201
|
break;
|
|
202
202
|
}
|
|
203
203
|
}
|
|
@@ -455,7 +455,7 @@ var require_utils = __commonJS({
|
|
|
455
455
|
module.exports = {
|
|
456
456
|
strlen,
|
|
457
457
|
repeat,
|
|
458
|
-
pad,
|
|
458
|
+
pad: pad2,
|
|
459
459
|
truncate,
|
|
460
460
|
mergeOptions,
|
|
461
461
|
wordWrap: multiLineWordWrap,
|
|
@@ -1902,7 +1902,7 @@ var symbols = {
|
|
|
1902
1902
|
ears: "\u23DC"
|
|
1903
1903
|
};
|
|
1904
1904
|
function renderTable(columns, rows, options) {
|
|
1905
|
-
const
|
|
1905
|
+
const pad2 = options?.padding ?? 1;
|
|
1906
1906
|
if (rows.length === 0) {
|
|
1907
1907
|
return pc.dim(" (no data)");
|
|
1908
1908
|
}
|
|
@@ -1910,8 +1910,8 @@ function renderTable(columns, rows, options) {
|
|
|
1910
1910
|
head: columns.map((c) => pc.bold(c.label)),
|
|
1911
1911
|
colAligns: columns.map((c) => c.align ?? "left"),
|
|
1912
1912
|
style: {
|
|
1913
|
-
"padding-left":
|
|
1914
|
-
"padding-right":
|
|
1913
|
+
"padding-left": pad2,
|
|
1914
|
+
"padding-right": pad2,
|
|
1915
1915
|
head: [],
|
|
1916
1916
|
// reset default styles; our pc.bold is already applied
|
|
1917
1917
|
border: [],
|
|
@@ -1955,8 +1955,8 @@ function renderTable(columns, rows, options) {
|
|
|
1955
1955
|
function renderKV(key, value, options) {
|
|
1956
1956
|
const indent = options?.indent ?? 2;
|
|
1957
1957
|
const sep = options?.separator ?? ":";
|
|
1958
|
-
const
|
|
1959
|
-
return `${
|
|
1958
|
+
const pad2 = " ".repeat(indent);
|
|
1959
|
+
return `${pad2}${pc.dim(key + sep)} ${value}`;
|
|
1960
1960
|
}
|
|
1961
1961
|
function renderKVColor(key, value, color = pc.bold) {
|
|
1962
1962
|
return ` ${pc.dim(key + ":")} ${color(value)}`;
|
|
@@ -2013,10 +2013,10 @@ async function selectPrompt(message, options) {
|
|
|
2013
2013
|
console.log(` ${pc.dim("0) Cancel")}
|
|
2014
2014
|
`);
|
|
2015
2015
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2016
|
-
const answer = await new Promise((
|
|
2016
|
+
const answer = await new Promise((resolve13) => {
|
|
2017
2017
|
rl.question(` ${pc.bold("Enter number")} ${pc.dim("(0-" + options.length + ")")}: `, (ans) => {
|
|
2018
2018
|
rl.close();
|
|
2019
|
-
|
|
2019
|
+
resolve13(ans.trim());
|
|
2020
2020
|
});
|
|
2021
2021
|
});
|
|
2022
2022
|
const num = parseInt(answer, 10);
|
|
@@ -2031,11 +2031,11 @@ async function selectPrompt(message, options) {
|
|
|
2031
2031
|
async function confirmPrompt(message, defaultValue = false) {
|
|
2032
2032
|
const hint = defaultValue ? pc.bold("Y") + pc.dim("/n") : pc.dim("y/") + pc.bold("N");
|
|
2033
2033
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2034
|
-
const answer = await new Promise((
|
|
2034
|
+
const answer = await new Promise((resolve13) => {
|
|
2035
2035
|
rl.question(`
|
|
2036
2036
|
${message} ${pc.dim("(" + hint + ")")}: `, (ans) => {
|
|
2037
2037
|
rl.close();
|
|
2038
|
-
|
|
2038
|
+
resolve13(ans.trim().toLowerCase());
|
|
2039
2039
|
});
|
|
2040
2040
|
});
|
|
2041
2041
|
if (!answer) return defaultValue;
|
|
@@ -2091,7 +2091,7 @@ function hexColor(color) {
|
|
|
2091
2091
|
}
|
|
2092
2092
|
var fennecOrange2 = hexColor("#FF6432");
|
|
2093
2093
|
var fennecGold = hexColor("#FFB347");
|
|
2094
|
-
var VERSION = "1.13.
|
|
2094
|
+
var VERSION = "1.13.6";
|
|
2095
2095
|
var cachedBanner = null;
|
|
2096
2096
|
var cachedCompact = null;
|
|
2097
2097
|
function generateBanner() {
|
|
@@ -2135,91 +2135,373 @@ var FOX_COMPACT = generateCompactBanner();
|
|
|
2135
2135
|
// src/utils/help.ts
|
|
2136
2136
|
import pc3 from "picocolors";
|
|
2137
2137
|
var fennecOrange3 = hexColor("#FF6432");
|
|
2138
|
+
var COMMANDS = {
|
|
2139
|
+
"start-server": {
|
|
2140
|
+
name: "start",
|
|
2141
|
+
usage: "start [options]",
|
|
2142
|
+
summary: "Start the Fennec MCP server (default when no app command is given)",
|
|
2143
|
+
description: "Boots the Fennec MCP server so AI agents can connect. Also auto-resurrects tracked apps that died since the last session.",
|
|
2144
|
+
options: [
|
|
2145
|
+
["--config <path>", "Path to a fennec.config.yaml file"],
|
|
2146
|
+
["--sse", "Use SSE (HTTP) transport instead of stdio"]
|
|
2147
|
+
],
|
|
2148
|
+
examples: ["start", "start --sse", "start --config ./fennec.config.yaml"]
|
|
2149
|
+
},
|
|
2150
|
+
start: {
|
|
2151
|
+
name: "start",
|
|
2152
|
+
usage: "start <command> --name <name> [options]",
|
|
2153
|
+
short: "start <command>",
|
|
2154
|
+
summary: "Start an app as a background daemon (like)",
|
|
2155
|
+
description: "Launches <command> as a detached daemon. Its output is written directly to ~/.fennec/logs/<name>.log, so Fennec confirms the app is really running and then exits \u2014 no Ctrl+C needed.",
|
|
2156
|
+
aliases: ["run"],
|
|
2157
|
+
options: [
|
|
2158
|
+
["--name <name>", "App name used for tracking (recommended)"],
|
|
2159
|
+
["--port <port>", "Port the app listens on \u2014 Fennec waits until it accepts connections"],
|
|
2160
|
+
["--cwd <dir>", "Working directory to run the command in"],
|
|
2161
|
+
["--restart", "Auto-restart if it crashes or its port stops listening (detached supervisor, survives terminal close)"]
|
|
2162
|
+
],
|
|
2163
|
+
examples: [
|
|
2164
|
+
'start "npm run dev" --name web --port 3000',
|
|
2165
|
+
"start node server.js --name api --cwd ./backend --restart"
|
|
2166
|
+
]
|
|
2167
|
+
},
|
|
2168
|
+
run: {
|
|
2169
|
+
name: "run",
|
|
2170
|
+
usage: "run <command> --name <name> [options]",
|
|
2171
|
+
short: "run <command>",
|
|
2172
|
+
summary: "Alias of `start <command>` \u2014 run an app under Fennec",
|
|
2173
|
+
aliases: ["start"],
|
|
2174
|
+
options: [
|
|
2175
|
+
["--name <name>", "App name used for tracking (recommended)"],
|
|
2176
|
+
["--port <port>", "Port the app listens on"],
|
|
2177
|
+
["--cwd <dir>", "Working directory"],
|
|
2178
|
+
["--restart", "Auto-restart if it crashes or its port stops listening"]
|
|
2179
|
+
],
|
|
2180
|
+
examples: ["run npm start --name web --port 8080"]
|
|
2181
|
+
},
|
|
2182
|
+
ps: {
|
|
2183
|
+
name: "ps",
|
|
2184
|
+
usage: "ps [options]",
|
|
2185
|
+
summary: "List Fennec-tracked apps with live status",
|
|
2186
|
+
description: "Shows tracked apps and whether they are genuinely running (PID + command identity are verified to avoid false positives from PID reuse).",
|
|
2187
|
+
options: [
|
|
2188
|
+
["--system, -a, --all", "Show all system processes instead of tracked apps"],
|
|
2189
|
+
["--name <name>", "Filter system processes by name"],
|
|
2190
|
+
["--sort <field>", "Sort by cpu | mem | pid | name (default: name)"],
|
|
2191
|
+
["--json", "Output tracked apps as JSON"],
|
|
2192
|
+
["-w, --watch", "Watch mode \u2014 refresh every 3s (with --system)"]
|
|
2193
|
+
],
|
|
2194
|
+
examples: ["ps", "ps --json", "ps --system --sort cpu -w"]
|
|
2195
|
+
},
|
|
2196
|
+
status: {
|
|
2197
|
+
name: "status",
|
|
2198
|
+
usage: "status [name] [options]",
|
|
2199
|
+
summary: "Show system overview & top processes",
|
|
2200
|
+
options: [["-w, --watch", "Watch mode \u2014 refresh every 3s"]],
|
|
2201
|
+
examples: ["status", "status -w"]
|
|
2202
|
+
},
|
|
2203
|
+
log: {
|
|
2204
|
+
name: "log",
|
|
2205
|
+
usage: "log <name|pid> [options]",
|
|
2206
|
+
summary: "Show (and follow) logs for a tracked app",
|
|
2207
|
+
description: "Prints the app's status (running/stopped) then its recent logs. Run without an app name to list all tracked apps to choose from. Secrets (API keys, tokens, connection strings, private keys) are redacted by default \u2014 safe to pipe to an AI assistant. Use --json for a bounded, machine-readable view (AI mode).",
|
|
2208
|
+
aliases: ["logs"],
|
|
2209
|
+
options: [
|
|
2210
|
+
["--lines <n>", "Number of lines to show (default: 30, capped at 500)"],
|
|
2211
|
+
["-f, --follow", "Follow mode \u2014 stream new log lines live (Ctrl+C to stop)"],
|
|
2212
|
+
["--level <level>", "Filter by level: error | warn | info | debug"],
|
|
2213
|
+
["--since <dur>", "Only show lines from the last duration (e.g. 10m, 1h, 30s)"],
|
|
2214
|
+
["--json", "Machine-readable JSON (AI mode) \u2014 bounded, redacted, no ANSI"],
|
|
2215
|
+
["--no-redact", "Disable secret redaction (use with care)"],
|
|
2216
|
+
["--clear", "Delete the log file for this app"]
|
|
2217
|
+
],
|
|
2218
|
+
examples: ["logs", "log web", "log web -f", "log api --level error --lines 100"]
|
|
2219
|
+
},
|
|
2220
|
+
spawn: {
|
|
2221
|
+
name: "spawn",
|
|
2222
|
+
usage: "spawn [name] [--all]",
|
|
2223
|
+
summary: "Re-spawn a stopped tracked app from its saved config",
|
|
2224
|
+
description: "Revives a previously stopped app using its saved command/args/cwd. With no name, shows an interactive list of stopped apps.",
|
|
2225
|
+
options: [["--all, -a", "Re-spawn all stopped apps that have a saved command"]],
|
|
2226
|
+
examples: ["spawn", "spawn web", "spawn --all"]
|
|
2227
|
+
},
|
|
2228
|
+
stop: {
|
|
2229
|
+
name: "stop",
|
|
2230
|
+
usage: "stop <name|--all>",
|
|
2231
|
+
summary: "Stop (pause) a tracked app but keep it in the registry",
|
|
2232
|
+
description: "Sends SIGTERM but keeps the entry in tracked.json so it can be revived later with `fennec spawn`.",
|
|
2233
|
+
options: [["--all, -a", "Stop all running tracked apps"]],
|
|
2234
|
+
examples: ["stop web", "stop --all"]
|
|
2235
|
+
},
|
|
2236
|
+
restart: {
|
|
2237
|
+
name: "restart",
|
|
2238
|
+
usage: "restart <name|pid>",
|
|
2239
|
+
summary: "Stop and re-spawn a tracked app from its saved config",
|
|
2240
|
+
examples: ["restart web"]
|
|
2241
|
+
},
|
|
2242
|
+
kill: {
|
|
2243
|
+
name: "kill",
|
|
2244
|
+
usage: "kill <pid|name|all>",
|
|
2245
|
+
summary: "Kill a process by PID, name, or kill all user processes",
|
|
2246
|
+
description: "Permanently removes a tracked app (unlike `stop`, which keeps it). Prompts before killing.",
|
|
2247
|
+
options: [
|
|
2248
|
+
["--signal <sig>", "Signal to send (default: SIGTERM). e.g. SIGKILL, SIGINT"],
|
|
2249
|
+
["--all, -a", "Kill all user processes (asks for confirmation)"]
|
|
2250
|
+
],
|
|
2251
|
+
examples: ["kill web", "kill 12345 --signal SIGKILL", "kill all"]
|
|
2252
|
+
},
|
|
2253
|
+
supervisor: {
|
|
2254
|
+
name: "supervisor",
|
|
2255
|
+
usage: "supervisor <start|stop|status|restart>",
|
|
2256
|
+
short: "supervisor <action>",
|
|
2257
|
+
summary: "Manage the background daemon that keeps --restart apps alive",
|
|
2258
|
+
description: "The supervisor is a detached daemon that auto-restarts apps started with --restart, even after you close the terminal. It polls every few seconds, health-checks each app's port (restarting ones that are alive but not listening) and has crash-loop backoff. `fennec start --restart` starts it automatically.",
|
|
2259
|
+
options: [
|
|
2260
|
+
["start", "Start the supervisor daemon"],
|
|
2261
|
+
["stop", "Stop the supervisor daemon"],
|
|
2262
|
+
["status", "Show supervisor status and managed apps"],
|
|
2263
|
+
["restart", "Restart the supervisor daemon"]
|
|
2264
|
+
],
|
|
2265
|
+
examples: ["supervisor status", "supervisor start", "supervisor stop"]
|
|
2266
|
+
},
|
|
2267
|
+
persist: {
|
|
2268
|
+
name: "persist",
|
|
2269
|
+
usage: "persist <enable|disable|status>",
|
|
2270
|
+
short: "persist <action>",
|
|
2271
|
+
summary: "Make fennec survive reboots (auto-start apps after login)",
|
|
2272
|
+
description: "Installs a boot unit (systemd user service on Linux, launchd agent on macOS, a startup script on Windows) that starts the supervisor at login. The supervisor then resurrects every app started with --restart, so your whole fleet comes back after a reboot \u2014 like `startup` + `save`. `fennec start --restart` enables this automatically.",
|
|
2273
|
+
options: [
|
|
2274
|
+
["enable", "Install the boot unit for the current user"],
|
|
2275
|
+
["disable", "Remove the boot unit"],
|
|
2276
|
+
["status", "Show whether boot persistence is active"]
|
|
2277
|
+
],
|
|
2278
|
+
examples: ["persist enable", "persist status", "persist disable"]
|
|
2279
|
+
},
|
|
2280
|
+
inspect: {
|
|
2281
|
+
name: "inspect",
|
|
2282
|
+
usage: "inspect <name|pid> [--plain] [--tail N] [--since 10m]",
|
|
2283
|
+
short: "inspect <name|pid>",
|
|
2284
|
+
summary: "Compact, AI-safe snapshot of an app (status + logs + errors)",
|
|
2285
|
+
description: "Returns a single bounded, machine-readable (JSON) view of one app: liveness, port health, uptime, memory, recent log lines and an error scan. Output is capped (token budget), secrets are redacted by default, and the shape is stable \u2014 so an AI assistant can observe a real running app (BE, FE, worker, console, ...) cheaply and predictably. Add --plain for a short human summary.",
|
|
2286
|
+
options: [
|
|
2287
|
+
["--plain", "Short human-readable summary instead of JSON"],
|
|
2288
|
+
["--tail N", "Max recent log lines (default 40, cap 200)"],
|
|
2289
|
+
["--since 10m|1h", "Only consider log lines from the last duration"]
|
|
2290
|
+
],
|
|
2291
|
+
examples: ["inspect web --plain", "inspect web --since 10m", "inspect api --tail 20"]
|
|
2292
|
+
},
|
|
2293
|
+
dev: {
|
|
2294
|
+
name: "dev",
|
|
2295
|
+
usage: "dev <up|down|status> [--config fennec.config.yaml]",
|
|
2296
|
+
short: "dev <action>",
|
|
2297
|
+
summary: "Orchestrate a whole dev stack from fennec.config.yaml",
|
|
2298
|
+
description: "Declarative dev-environment orchestration. `fennec dev up` reads fennec.config.yaml, starts every app (BE/FE/DB/worker) like `fennec start --restart`, respects dependencies (waits for a dependent's port), and brings the whole stack up as one unit. Apps are supervised (auto-restart + port health-check) and boot-persistent. `fennec dev down` stops them (disabling auto-restart). `fennec dev status` shows the stack. This is the AI-friendly way to boot an entire project and then observe it with `fennec observe` / `inspect`.",
|
|
2299
|
+
options: [
|
|
2300
|
+
["up", "Start the whole stack from fennec.config.yaml"],
|
|
2301
|
+
["down", "Stop the whole stack (auto-restart disabled)"],
|
|
2302
|
+
["status", "Show stack status (running/stopped, last restart cause)"],
|
|
2303
|
+
["--config <path>", "Path to the config file (default: ./fennec.config.yaml)"]
|
|
2304
|
+
],
|
|
2305
|
+
examples: ["dev up", "dev up --config ./stack.yaml", "dev down", "dev status"]
|
|
2306
|
+
},
|
|
2307
|
+
info: {
|
|
2308
|
+
name: "info",
|
|
2309
|
+
usage: "info <name>",
|
|
2310
|
+
summary: "Show detailed info for a tracked app",
|
|
2311
|
+
examples: ["info web"]
|
|
2312
|
+
},
|
|
2313
|
+
rename: {
|
|
2314
|
+
name: "rename",
|
|
2315
|
+
usage: "rename <old-name> <new-name>",
|
|
2316
|
+
summary: "Rename a tracked app",
|
|
2317
|
+
examples: ["rename web frontend"]
|
|
2318
|
+
},
|
|
2319
|
+
attach: {
|
|
2320
|
+
name: "attach",
|
|
2321
|
+
usage: "attach <port> --name <name>",
|
|
2322
|
+
short: "attach <port>",
|
|
2323
|
+
summary: "Observe a running process by the port it listens on",
|
|
2324
|
+
options: [["--name <name>", "Name to track the observed process under"]],
|
|
2325
|
+
examples: ["attach 3000 --name web"]
|
|
2326
|
+
},
|
|
2327
|
+
"attach-pid": {
|
|
2328
|
+
name: "attach-pid",
|
|
2329
|
+
usage: "attach-pid <pid>",
|
|
2330
|
+
summary: "Attach to and observe a process by its PID",
|
|
2331
|
+
examples: ["attach-pid 12345"]
|
|
2332
|
+
},
|
|
2333
|
+
"attach-port": {
|
|
2334
|
+
name: "attach-port",
|
|
2335
|
+
usage: "attach-port <port>",
|
|
2336
|
+
summary: "Attach to and observe a process by its port",
|
|
2337
|
+
examples: ["attach-port 8080"]
|
|
2338
|
+
},
|
|
2339
|
+
pipe: {
|
|
2340
|
+
name: "pipe",
|
|
2341
|
+
usage: "pipe --name <name>",
|
|
2342
|
+
summary: "Pipe stdin into a Fennec log watcher",
|
|
2343
|
+
options: [["--name <name>", "Watcher name (required)"]],
|
|
2344
|
+
examples: ["npm run dev | fennec pipe --name web"]
|
|
2345
|
+
},
|
|
2346
|
+
watch: {
|
|
2347
|
+
name: "watch",
|
|
2348
|
+
usage: "watch --file <path> [--name <name>]",
|
|
2349
|
+
short: "watch --file <path>",
|
|
2350
|
+
summary: "Watch an existing log file",
|
|
2351
|
+
options: [
|
|
2352
|
+
["--file <path>", "Path to the log file (required)"],
|
|
2353
|
+
["--name <name>", "Watcher name"]
|
|
2354
|
+
],
|
|
2355
|
+
examples: ["watch --file ./app.log --name web"]
|
|
2356
|
+
},
|
|
2357
|
+
export: {
|
|
2358
|
+
name: "export",
|
|
2359
|
+
usage: "export --file <path>",
|
|
2360
|
+
summary: "Export tracked apps to a file",
|
|
2361
|
+
options: [["--file <path>", "Destination file path (required)"]],
|
|
2362
|
+
examples: ["export --file ./fennec-apps.json"]
|
|
2363
|
+
},
|
|
2364
|
+
import: {
|
|
2365
|
+
name: "import",
|
|
2366
|
+
usage: "import <file>",
|
|
2367
|
+
summary: "Import tracked apps from a file",
|
|
2368
|
+
examples: ["import ./fennec-apps.json"]
|
|
2369
|
+
},
|
|
2370
|
+
cleanup: {
|
|
2371
|
+
name: "cleanup",
|
|
2372
|
+
usage: "cleanup",
|
|
2373
|
+
summary: "Remove dead/stale entries from the tracked registry",
|
|
2374
|
+
examples: ["cleanup"]
|
|
2375
|
+
},
|
|
2376
|
+
init: {
|
|
2377
|
+
name: "init",
|
|
2378
|
+
usage: "init",
|
|
2379
|
+
summary: "Generate a fennec.config.yaml in the current directory",
|
|
2380
|
+
examples: ["init"]
|
|
2381
|
+
},
|
|
2382
|
+
setup: {
|
|
2383
|
+
name: "setup",
|
|
2384
|
+
usage: "setup",
|
|
2385
|
+
summary: "Interactively configure your MCP client for Fennec",
|
|
2386
|
+
examples: ["setup"]
|
|
2387
|
+
},
|
|
2388
|
+
"install-browsers": {
|
|
2389
|
+
name: "install-browsers",
|
|
2390
|
+
usage: "install-browsers",
|
|
2391
|
+
summary: "Install Playwright browser engines",
|
|
2392
|
+
examples: ["install-browsers"]
|
|
2393
|
+
},
|
|
2394
|
+
sessions: {
|
|
2395
|
+
name: "sessions",
|
|
2396
|
+
usage: "sessions",
|
|
2397
|
+
summary: "List saved browser auth sessions",
|
|
2398
|
+
examples: ["sessions"]
|
|
2399
|
+
},
|
|
2400
|
+
health: {
|
|
2401
|
+
name: "health",
|
|
2402
|
+
usage: "health",
|
|
2403
|
+
summary: "Run a health check of the Fennec environment",
|
|
2404
|
+
examples: ["health"]
|
|
2405
|
+
},
|
|
2406
|
+
help: {
|
|
2407
|
+
name: "help",
|
|
2408
|
+
usage: "help [command]",
|
|
2409
|
+
summary: "Show this help, or detailed help for a command",
|
|
2410
|
+
examples: ["help", "help start", "start --help"]
|
|
2411
|
+
},
|
|
2412
|
+
version: {
|
|
2413
|
+
name: "version",
|
|
2414
|
+
usage: "version",
|
|
2415
|
+
summary: "Show the installed Fennec version",
|
|
2416
|
+
examples: ["version"]
|
|
2417
|
+
}
|
|
2418
|
+
};
|
|
2419
|
+
var LOOKUP = {
|
|
2420
|
+
logs: "log",
|
|
2421
|
+
"-v": "version",
|
|
2422
|
+
"--version": "version",
|
|
2423
|
+
"-h": "help",
|
|
2424
|
+
"--help": "help"
|
|
2425
|
+
};
|
|
2426
|
+
function findCommandDoc(cmd) {
|
|
2427
|
+
if (COMMANDS[cmd]) return COMMANDS[cmd];
|
|
2428
|
+
const alias = LOOKUP[cmd];
|
|
2429
|
+
return alias ? COMMANDS[alias] : void 0;
|
|
2430
|
+
}
|
|
2431
|
+
var GROUPS = [
|
|
2432
|
+
{ title: "Server", keys: ["start-server"] },
|
|
2433
|
+
{ title: "Apps & Processes", keys: ["start", "run", "ps", "status", "log", "spawn", "stop", "restart", "kill", "supervisor", "persist", "dev", "inspect", "info", "rename"] },
|
|
2434
|
+
{ title: "Observation", keys: ["attach", "attach-pid", "attach-port", "pipe", "watch"] },
|
|
2435
|
+
{ title: "Data", keys: ["export", "import", "cleanup"] },
|
|
2436
|
+
{ title: "Configuration", keys: ["init", "setup", "install-browsers", "sessions"] },
|
|
2437
|
+
{ title: "Other", keys: ["health", "help", "version"] }
|
|
2438
|
+
];
|
|
2439
|
+
function pad(s, width) {
|
|
2440
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
2441
|
+
}
|
|
2138
2442
|
function showHelp() {
|
|
2139
|
-
const sep = fennecOrange3("\u2500".repeat(
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
${pc3.bold("Usage:")} fennec ${pc3.dim("<command>")} ${pc3.dim("[options]")}
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
${sep}
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
${
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
${pc3.
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
${
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
${pc3.
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
${pc3.cyan("attach-port")} ${pc3.dim("<port>")} ${pc3.dim("Attach to process by port")}
|
|
2202
|
-
|
|
2203
|
-
${sep}
|
|
2204
|
-
${pc3.bold("Configuration")}
|
|
2205
|
-
${sep}
|
|
2206
|
-
|
|
2207
|
-
${pc3.cyan("init")} ${pc3.dim("Generate fennec.config.yaml")}
|
|
2208
|
-
${pc3.cyan("setup")} ${pc3.dim("Configure MCP client")}
|
|
2209
|
-
${pc3.cyan("install-browsers")} ${pc3.dim("Install Playwright browser engines")}
|
|
2210
|
-
${pc3.cyan("sessions")} ${pc3.dim("List saved auth sessions")}
|
|
2211
|
-
|
|
2212
|
-
${sep}
|
|
2213
|
-
${pc3.bold("Other")}
|
|
2214
|
-
${sep}
|
|
2215
|
-
|
|
2216
|
-
${pc3.cyan("help")} ${pc3.dim("Show this help message")}
|
|
2217
|
-
${pc3.cyan("version")} ${pc3.dim("Show version")}
|
|
2218
|
-
|
|
2219
|
-
${pc3.dim("\u2500".repeat(50))}
|
|
2220
|
-
|
|
2221
|
-
${pc3.dim("Learn more:")} ${pc3.cyan("https://github.com/plumpslabs/fennec")}
|
|
2222
|
-
`);
|
|
2443
|
+
const sep = fennecOrange3("\u2500".repeat(56));
|
|
2444
|
+
const lines = [];
|
|
2445
|
+
lines.push("");
|
|
2446
|
+
lines.push(` ${pc3.bold("Usage:")} fennec ${pc3.dim("<command>")} ${pc3.dim("[options]")}`);
|
|
2447
|
+
lines.push(` ${pc3.dim("Run")} ${pc3.cyan("fennec <command> --help")} ${pc3.dim("for details on any command.")}`);
|
|
2448
|
+
for (const group of GROUPS) {
|
|
2449
|
+
lines.push("");
|
|
2450
|
+
lines.push(` ${sep}`);
|
|
2451
|
+
lines.push(` ${pc3.bold(group.title)}`);
|
|
2452
|
+
lines.push(` ${sep}`);
|
|
2453
|
+
lines.push("");
|
|
2454
|
+
for (const key of group.keys) {
|
|
2455
|
+
const doc = COMMANDS[key];
|
|
2456
|
+
if (!doc) continue;
|
|
2457
|
+
const usageLabel = key === "start-server" ? "start" : doc.short ?? doc.usage;
|
|
2458
|
+
lines.push(` ${pc3.cyan(pad(usageLabel, 24))} ${pc3.dim(doc.summary)}`);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
lines.push("");
|
|
2462
|
+
lines.push(` ${pc3.dim("\u2500".repeat(56))}`);
|
|
2463
|
+
lines.push(` ${pc3.dim("Learn more:")} ${pc3.cyan("https://github.com/plumpslabs/fennec")}`);
|
|
2464
|
+
lines.push("");
|
|
2465
|
+
console.error(lines.join("\n"));
|
|
2466
|
+
}
|
|
2467
|
+
function showCommandHelp(cmd) {
|
|
2468
|
+
const doc = findCommandDoc(cmd);
|
|
2469
|
+
if (!doc) {
|
|
2470
|
+
console.error(`
|
|
2471
|
+
${pc3.yellow("\u26A0")} Unknown command: ${pc3.bold(cmd)}`);
|
|
2472
|
+
console.error(` ${pc3.dim("Run")} ${pc3.cyan("fennec help")} ${pc3.dim("to see all commands.")}
|
|
2473
|
+
`);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const lines = [];
|
|
2477
|
+
lines.push("");
|
|
2478
|
+
lines.push(` ${pc3.bold(fennecOrange3(doc.name))} ${pc3.dim("\u2014")} ${doc.summary}`);
|
|
2479
|
+
lines.push("");
|
|
2480
|
+
lines.push(` ${pc3.bold("Usage:")} ${pc3.cyan(`fennec ${doc.usage}`)}`);
|
|
2481
|
+
if (doc.aliases?.length) {
|
|
2482
|
+
lines.push(` ${pc3.bold("Aliases:")} ${doc.aliases.map((a) => pc3.cyan(a)).join(", ")}`);
|
|
2483
|
+
}
|
|
2484
|
+
if (doc.description) {
|
|
2485
|
+
lines.push("");
|
|
2486
|
+
lines.push(` ${doc.description}`);
|
|
2487
|
+
}
|
|
2488
|
+
if (doc.options?.length) {
|
|
2489
|
+
lines.push("");
|
|
2490
|
+
lines.push(` ${pc3.bold("Options:")}`);
|
|
2491
|
+
const width = Math.max(...doc.options.map(([f]) => f.length));
|
|
2492
|
+
for (const [flag, desc] of doc.options) {
|
|
2493
|
+
lines.push(` ${pc3.cyan(pad(flag, width))} ${pc3.dim(desc)}`);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
if (doc.examples?.length) {
|
|
2497
|
+
lines.push("");
|
|
2498
|
+
lines.push(` ${pc3.bold("Examples:")}`);
|
|
2499
|
+
for (const ex of doc.examples) {
|
|
2500
|
+
lines.push(` ${pc3.dim("$")} ${fennecOrange3(`fennec ${ex}`)}`);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
lines.push("");
|
|
2504
|
+
console.error(lines.join("\n"));
|
|
2223
2505
|
}
|
|
2224
2506
|
|
|
2225
2507
|
// src/commands/pipe.ts
|
|
@@ -2330,105 +2612,22 @@ async function watchCommand(args2) {
|
|
|
2330
2612
|
}
|
|
2331
2613
|
|
|
2332
2614
|
// src/commands/start.ts
|
|
2333
|
-
import {
|
|
2334
|
-
import { resolve as
|
|
2335
|
-
import { homedir as homedir2 } from "os";
|
|
2336
|
-
import { spawn } from "child_process";
|
|
2615
|
+
import { mkdirSync as mkdirSync4 } from "fs";
|
|
2616
|
+
import { resolve as resolve4, dirname as dirname4 } from "path";
|
|
2337
2617
|
import { FennecServer } from "@plumpslabs/fennec-core";
|
|
2338
|
-
import
|
|
2618
|
+
import pc7 from "picocolors";
|
|
2339
2619
|
|
|
2340
2620
|
// src/commands/tracker.ts
|
|
2341
|
-
import { existsSync as
|
|
2621
|
+
import { existsSync as existsSync3, writeFileSync, readFileSync as readFileSync2, mkdirSync, renameSync, statSync, rmSync, openSync, closeSync, writeSync } from "fs";
|
|
2622
|
+
import { spawn } from "child_process";
|
|
2342
2623
|
import { resolve as resolve2, dirname, basename } from "path";
|
|
2343
2624
|
import { homedir } from "os";
|
|
2344
|
-
function getTrackedPath() {
|
|
2345
|
-
const dir = process.env.FENNEC_DATA_DIR ? resolve2(process.env.FENNEC_DATA_DIR) : resolve2(homedir(), ".fennec");
|
|
2346
|
-
return resolve2(dir, "tracked.json");
|
|
2347
|
-
}
|
|
2348
|
-
function readTracked() {
|
|
2349
|
-
try {
|
|
2350
|
-
const path = getTrackedPath();
|
|
2351
|
-
if (!existsSync2(path)) return [];
|
|
2352
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
2353
|
-
} catch {
|
|
2354
|
-
return [];
|
|
2355
|
-
}
|
|
2356
|
-
}
|
|
2357
|
-
function saveTracked(processes) {
|
|
2358
|
-
try {
|
|
2359
|
-
const path = getTrackedPath();
|
|
2360
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
2361
|
-
writeFileSync(path, JSON.stringify(processes, null, 2), "utf-8");
|
|
2362
|
-
} catch (err) {
|
|
2363
|
-
console.error("[fennec] Failed to save tracked processes:", err instanceof Error ? err.message : String(err));
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
function addTracked(proc) {
|
|
2367
|
-
const tracked = readTracked();
|
|
2368
|
-
const filtered = tracked.filter((t) => t.name !== proc.name);
|
|
2369
|
-
filtered.push(proc);
|
|
2370
|
-
saveTracked(filtered);
|
|
2371
|
-
}
|
|
2372
|
-
function removeTracked(name) {
|
|
2373
|
-
const tracked = readTracked();
|
|
2374
|
-
saveTracked(tracked.filter((t) => t.name !== name));
|
|
2375
|
-
}
|
|
2376
|
-
function removeTrackedByPid(pid) {
|
|
2377
|
-
const tracked = readTracked();
|
|
2378
|
-
saveTracked(tracked.filter((t) => t.pid !== pid));
|
|
2379
|
-
}
|
|
2380
|
-
function formatUptime(seconds) {
|
|
2381
|
-
if (seconds < 60) return `${seconds}s`;
|
|
2382
|
-
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
2383
|
-
const h = Math.floor(seconds / 3600);
|
|
2384
|
-
const m = Math.floor(seconds % 3600 / 60);
|
|
2385
|
-
if (h >= 24) {
|
|
2386
|
-
const d = Math.floor(h / 24);
|
|
2387
|
-
return `${d}d ${h % 24}h ${m}m`;
|
|
2388
|
-
}
|
|
2389
|
-
return `${h}h ${m}m`;
|
|
2390
|
-
}
|
|
2391
|
-
var DEFAULT_MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
2392
|
-
var DEFAULT_MAX_LOG_FILES = 3;
|
|
2393
|
-
function rotateLogFile(filePath, maxSize = DEFAULT_MAX_LOG_SIZE, maxFiles = DEFAULT_MAX_LOG_FILES) {
|
|
2394
|
-
try {
|
|
2395
|
-
if (!existsSync2(filePath)) return false;
|
|
2396
|
-
const stats = statSync(filePath);
|
|
2397
|
-
if (stats.size < maxSize) return false;
|
|
2398
|
-
const dir = dirname(filePath);
|
|
2399
|
-
const base = basename(filePath);
|
|
2400
|
-
const oldestPath = resolve2(dir, `${base}.${maxFiles}`);
|
|
2401
|
-
if (existsSync2(oldestPath)) {
|
|
2402
|
-
try {
|
|
2403
|
-
rmSync(oldestPath);
|
|
2404
|
-
} catch {
|
|
2405
|
-
}
|
|
2406
|
-
}
|
|
2407
|
-
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
2408
|
-
const src = resolve2(dir, `${base}.${i}`);
|
|
2409
|
-
const dst = resolve2(dir, `${base}.${i + 1}`);
|
|
2410
|
-
if (existsSync2(src)) {
|
|
2411
|
-
try {
|
|
2412
|
-
renameSync(src, dst);
|
|
2413
|
-
} catch {
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2417
|
-
try {
|
|
2418
|
-
renameSync(filePath, resolve2(dir, `${base}.1`));
|
|
2419
|
-
} catch {
|
|
2420
|
-
}
|
|
2421
|
-
return true;
|
|
2422
|
-
} catch (err) {
|
|
2423
|
-
console.error("[fennec] Log rotation failed:", err instanceof Error ? err.message : String(err));
|
|
2424
|
-
return false;
|
|
2425
|
-
}
|
|
2426
|
-
}
|
|
2427
2625
|
|
|
2428
2626
|
// src/utils/system-process.ts
|
|
2429
|
-
import { readdirSync, readFileSync
|
|
2627
|
+
import { readdirSync, readFileSync, existsSync as existsSync2, readlinkSync } from "fs";
|
|
2430
2628
|
import { execSync } from "child_process";
|
|
2431
2629
|
import { cpus } from "os";
|
|
2630
|
+
import net from "net";
|
|
2432
2631
|
function readProcProcesses() {
|
|
2433
2632
|
const processes = [];
|
|
2434
2633
|
const currentUid = process.getuid?.();
|
|
@@ -2453,7 +2652,7 @@ function readSingleProcProcess(pid, currentUid, totalMemKb) {
|
|
|
2453
2652
|
const statPath = `/proc/${pid}/stat`;
|
|
2454
2653
|
const statusPath = `/proc/${pid}/status`;
|
|
2455
2654
|
const cmdlinePath = `/proc/${pid}/cmdline`;
|
|
2456
|
-
if (!
|
|
2655
|
+
if (!existsSync2(statPath)) return null;
|
|
2457
2656
|
const statusText = readSafe(statusPath);
|
|
2458
2657
|
if (!statusText) return null;
|
|
2459
2658
|
const nameMatch = statusText.match(/Name:\s+(.+)/);
|
|
@@ -2539,6 +2738,36 @@ function runPsProcesses() {
|
|
|
2539
2738
|
return [];
|
|
2540
2739
|
}
|
|
2541
2740
|
}
|
|
2741
|
+
function runTasklistProcesses() {
|
|
2742
|
+
try {
|
|
2743
|
+
const output = execSync("tasklist /fo csv /nh 2>nul", { encoding: "utf-8", timeout: 5e3 });
|
|
2744
|
+
const result = [];
|
|
2745
|
+
for (const raw of output.split("\n")) {
|
|
2746
|
+
if (!raw.trim()) continue;
|
|
2747
|
+
const parts = raw.split('","').map((p) => p.replace(/^"|"$/g, ""));
|
|
2748
|
+
const pid = parseInt(parts[1] ?? "", 10);
|
|
2749
|
+
if (isNaN(pid)) continue;
|
|
2750
|
+
const image = parts[0] ?? "";
|
|
2751
|
+
const name = image.replace(/\.[a-z0-9]+$/i, "");
|
|
2752
|
+
const memKb = parseInt((parts[4] ?? "").replace(/[^\d]/g, ""), 10) || 0;
|
|
2753
|
+
result.push({
|
|
2754
|
+
pid,
|
|
2755
|
+
name,
|
|
2756
|
+
command: image,
|
|
2757
|
+
cpuPercent: 0,
|
|
2758
|
+
memPercent: 0,
|
|
2759
|
+
memRss: memKb,
|
|
2760
|
+
state: "R",
|
|
2761
|
+
startedAt: null,
|
|
2762
|
+
ports: [],
|
|
2763
|
+
isUserProcess: false
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
return result;
|
|
2767
|
+
} catch {
|
|
2768
|
+
return [];
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2542
2771
|
function mapBsdState(s) {
|
|
2543
2772
|
switch (s) {
|
|
2544
2773
|
case "R":
|
|
@@ -2558,129 +2787,1035 @@ function mapBsdState(s) {
|
|
|
2558
2787
|
function detectListeningPorts(_pid) {
|
|
2559
2788
|
return [];
|
|
2560
2789
|
}
|
|
2561
|
-
function readSafe(path) {
|
|
2562
|
-
try {
|
|
2563
|
-
return
|
|
2564
|
-
} catch {
|
|
2565
|
-
return null;
|
|
2790
|
+
function readSafe(path) {
|
|
2791
|
+
try {
|
|
2792
|
+
return readFileSync(path, "utf-8");
|
|
2793
|
+
} catch {
|
|
2794
|
+
return null;
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
function readTotalMem() {
|
|
2798
|
+
try {
|
|
2799
|
+
const memInfo = readSafe("/proc/meminfo");
|
|
2800
|
+
if (memInfo) {
|
|
2801
|
+
const match = memInfo.match(/MemTotal:\s+(\d+)/);
|
|
2802
|
+
if (match) return parseInt(match[1], 10);
|
|
2803
|
+
}
|
|
2804
|
+
} catch {
|
|
2805
|
+
}
|
|
2806
|
+
return 16e6;
|
|
2807
|
+
}
|
|
2808
|
+
function readUptime() {
|
|
2809
|
+
try {
|
|
2810
|
+
const data = readSafe("/proc/uptime");
|
|
2811
|
+
if (data) {
|
|
2812
|
+
return parseFloat(data.split(/\s+/)[0] ?? "0");
|
|
2813
|
+
}
|
|
2814
|
+
} catch {
|
|
2815
|
+
}
|
|
2816
|
+
return 0;
|
|
2817
|
+
}
|
|
2818
|
+
function formatStartTime(starttime, uptimeSeconds, hertz) {
|
|
2819
|
+
try {
|
|
2820
|
+
const bootTime = Date.now() / 1e3 - uptimeSeconds;
|
|
2821
|
+
const processStart = bootTime + starttime / hertz;
|
|
2822
|
+
return new Date(processStart * 1e3).toISOString();
|
|
2823
|
+
} catch {
|
|
2824
|
+
return null;
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
function getSystemProcesses(filter) {
|
|
2828
|
+
let processes;
|
|
2829
|
+
if (process.platform === "win32") {
|
|
2830
|
+
processes = runTasklistProcesses();
|
|
2831
|
+
} else if (existsSync2("/proc")) {
|
|
2832
|
+
processes = readProcProcesses();
|
|
2833
|
+
} else {
|
|
2834
|
+
processes = runPsProcesses();
|
|
2835
|
+
}
|
|
2836
|
+
if (filter) {
|
|
2837
|
+
if (filter.userOnly) {
|
|
2838
|
+
processes = processes.filter((p) => p.isUserProcess);
|
|
2839
|
+
}
|
|
2840
|
+
if (filter.name) {
|
|
2841
|
+
const nameLower = filter.name.toLowerCase();
|
|
2842
|
+
processes = processes.filter(
|
|
2843
|
+
(p) => p.name.toLowerCase().includes(nameLower) || p.command.toLowerCase().includes(nameLower)
|
|
2844
|
+
);
|
|
2845
|
+
}
|
|
2846
|
+
if (filter.pid) {
|
|
2847
|
+
processes = processes.filter((p) => p.pid === filter.pid);
|
|
2848
|
+
}
|
|
2849
|
+
if (filter.port) {
|
|
2850
|
+
processes = processes.filter((p) => p.ports.includes(filter.port));
|
|
2851
|
+
}
|
|
2852
|
+
if (filter.sortBy) {
|
|
2853
|
+
processes.sort((a, b) => {
|
|
2854
|
+
switch (filter.sortBy) {
|
|
2855
|
+
case "cpu":
|
|
2856
|
+
return b.cpuPercent - a.cpuPercent;
|
|
2857
|
+
case "mem":
|
|
2858
|
+
return b.memPercent - a.memPercent;
|
|
2859
|
+
case "pid":
|
|
2860
|
+
return a.pid - b.pid;
|
|
2861
|
+
case "name":
|
|
2862
|
+
return a.name.localeCompare(b.name);
|
|
2863
|
+
default:
|
|
2864
|
+
return b.cpuPercent - a.cpuPercent;
|
|
2865
|
+
}
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
if (filter.limit && filter.limit > 0) {
|
|
2869
|
+
processes = processes.slice(0, filter.limit);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
return processes;
|
|
2873
|
+
}
|
|
2874
|
+
function killProcess(pid, signal = "SIGTERM") {
|
|
2875
|
+
try {
|
|
2876
|
+
process.kill(pid, signal);
|
|
2877
|
+
return true;
|
|
2878
|
+
} catch {
|
|
2879
|
+
return false;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
function isProcessRunning(pid) {
|
|
2883
|
+
try {
|
|
2884
|
+
process.kill(pid, 0);
|
|
2885
|
+
return true;
|
|
2886
|
+
} catch {
|
|
2887
|
+
return false;
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
function checkPort(port, host = "127.0.0.1", timeout = 1e3) {
|
|
2891
|
+
const tryHost = (h) => new Promise((resolve13) => {
|
|
2892
|
+
const socket = new net.Socket();
|
|
2893
|
+
let settled = false;
|
|
2894
|
+
const finish = (result) => {
|
|
2895
|
+
if (settled) return;
|
|
2896
|
+
settled = true;
|
|
2897
|
+
socket.destroy();
|
|
2898
|
+
resolve13(result);
|
|
2899
|
+
};
|
|
2900
|
+
socket.setTimeout(timeout);
|
|
2901
|
+
socket.once("connect", () => finish(true));
|
|
2902
|
+
socket.once("timeout", () => finish(false));
|
|
2903
|
+
socket.once("error", () => finish(false));
|
|
2904
|
+
socket.connect(port, h);
|
|
2905
|
+
});
|
|
2906
|
+
return tryHost(host).then((ok) => ok ? true : tryHost("::1"));
|
|
2907
|
+
}
|
|
2908
|
+
async function checkHttp(url, timeout = 2e3) {
|
|
2909
|
+
const controller = new AbortController();
|
|
2910
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
2911
|
+
try {
|
|
2912
|
+
const res = await fetch(url, { method: "GET", signal: controller.signal, redirect: "manual" });
|
|
2913
|
+
return res.status >= 200 && res.status < 400;
|
|
2914
|
+
} catch {
|
|
2915
|
+
return false;
|
|
2916
|
+
} finally {
|
|
2917
|
+
clearTimeout(timer);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
function resolveHealthUrl(healthCheck, port) {
|
|
2921
|
+
if (/^https?:\/\//i.test(healthCheck)) return healthCheck;
|
|
2922
|
+
if (healthCheck.startsWith("/")) {
|
|
2923
|
+
if (!port) return null;
|
|
2924
|
+
return `http://127.0.0.1:${port}${healthCheck}`;
|
|
2925
|
+
}
|
|
2926
|
+
return null;
|
|
2927
|
+
}
|
|
2928
|
+
function findPidOnPort(port) {
|
|
2929
|
+
if (process.platform === "win32") return findPidOnPortWindows(port);
|
|
2930
|
+
if (process.platform === "darwin") return findPidOnPortMac(port);
|
|
2931
|
+
return findPidOnPortLinux(port);
|
|
2932
|
+
}
|
|
2933
|
+
function findPidOnPortLinux(port) {
|
|
2934
|
+
try {
|
|
2935
|
+
const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
|
|
2936
|
+
const inodes = /* @__PURE__ */ new Set();
|
|
2937
|
+
for (const f of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
2938
|
+
if (!existsSync2(f)) continue;
|
|
2939
|
+
for (const line of readFileSync(f, "utf-8").split("\n").slice(1)) {
|
|
2940
|
+
const cols = line.trim().split(/\s+/);
|
|
2941
|
+
if (cols.length < 10) continue;
|
|
2942
|
+
if (cols[3] !== "0A") continue;
|
|
2943
|
+
const localPort = cols[1]?.split(":")[1];
|
|
2944
|
+
const inode = parseInt(cols[9] ?? "", 10);
|
|
2945
|
+
if (localPort === hexPort && !Number.isNaN(inode)) inodes.add(inode);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
if (inodes.size === 0) return null;
|
|
2949
|
+
for (const pidStr of readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
|
|
2950
|
+
const pid = parseInt(pidStr, 10);
|
|
2951
|
+
try {
|
|
2952
|
+
const fdDir = `/proc/${pid}/fd`;
|
|
2953
|
+
for (const fd of readdirSync(fdDir)) {
|
|
2954
|
+
const link = readlinkSync(`${fdDir}/${fd}`);
|
|
2955
|
+
const m = link.match(/socket:\[(\d+)\]/);
|
|
2956
|
+
if (m && inodes.has(parseInt(m[1], 10))) {
|
|
2957
|
+
return { pid, command: getProcessCmdline(pid) ?? "" };
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
} catch {
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
return null;
|
|
2964
|
+
} catch {
|
|
2965
|
+
return null;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
function findPidOnPortMac(port) {
|
|
2969
|
+
try {
|
|
2970
|
+
const out = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n 2>/dev/null`, { encoding: "utf-8", timeout: 5e3 });
|
|
2971
|
+
for (const line of out.split("\n")) {
|
|
2972
|
+
if (!line.includes("LISTEN")) continue;
|
|
2973
|
+
const parts = line.trim().split(/\s+/);
|
|
2974
|
+
if (parts.length >= 2) {
|
|
2975
|
+
const pid = parseInt(parts[1], 10);
|
|
2976
|
+
if (!isNaN(pid)) return { pid, command: parts[0] ?? "" };
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
return null;
|
|
2980
|
+
} catch {
|
|
2981
|
+
return null;
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
function findPidOnPortWindows(port) {
|
|
2985
|
+
try {
|
|
2986
|
+
const out = execSync(`netstat -ano -p tcp 2>nul`, { encoding: "utf-8", timeout: 5e3 });
|
|
2987
|
+
const target = `:${port} `;
|
|
2988
|
+
for (const line of out.split("\n")) {
|
|
2989
|
+
const upper = line.toUpperCase();
|
|
2990
|
+
if (!upper.includes("LISTENING")) continue;
|
|
2991
|
+
if (!upper.includes(target)) continue;
|
|
2992
|
+
const pid = parseInt(line.trim().split(/\s+/).pop() ?? "", 10);
|
|
2993
|
+
if (!isNaN(pid)) return { pid, command: getProcessCmdline(pid) ?? "" };
|
|
2994
|
+
}
|
|
2995
|
+
return null;
|
|
2996
|
+
} catch {
|
|
2997
|
+
return null;
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
function getProcessCmdline(pid) {
|
|
3001
|
+
if (process.platform === "win32") return getProcessCmdlineWindows(pid);
|
|
3002
|
+
if (process.platform === "darwin") return getProcessCmdlineMac(pid);
|
|
3003
|
+
return getProcessCmdlineLinux(pid);
|
|
3004
|
+
}
|
|
3005
|
+
function getProcessCmdlineLinux(pid) {
|
|
3006
|
+
try {
|
|
3007
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
|
|
3008
|
+
const joined = raw.split("\0").filter(Boolean).join(" ").trim();
|
|
3009
|
+
return joined.length > 0 ? joined : null;
|
|
3010
|
+
} catch {
|
|
3011
|
+
return null;
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
function getProcessCmdlineMac(pid) {
|
|
3015
|
+
try {
|
|
3016
|
+
const out = execSync(`ps -p ${pid} -o command=`, { encoding: "utf-8", timeout: 3e3 });
|
|
3017
|
+
const trimmed = out.trim();
|
|
3018
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3019
|
+
} catch {
|
|
3020
|
+
return null;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
function getProcessCmdlineWindows(pid) {
|
|
3024
|
+
try {
|
|
3025
|
+
const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /value 2>nul`, { encoding: "utf-8", timeout: 3e3 });
|
|
3026
|
+
const line = out.split("\n").find((l) => l.includes("="));
|
|
3027
|
+
const value = line?.split("=")[1]?.trim();
|
|
3028
|
+
return value ? value : null;
|
|
3029
|
+
} catch {
|
|
3030
|
+
return null;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
function getProcessCwd(pid) {
|
|
3034
|
+
if (process.platform === "linux") {
|
|
3035
|
+
try {
|
|
3036
|
+
return readlinkSync(`/proc/${pid}/cwd`) || null;
|
|
3037
|
+
} catch {
|
|
3038
|
+
return null;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
if (process.platform === "darwin") {
|
|
3042
|
+
try {
|
|
3043
|
+
const out = execSync(`lsof -p ${pid} -a -d cwd -F n 2>/dev/null`, { encoding: "utf-8", timeout: 3e3 });
|
|
3044
|
+
const line = out.split("\n").find((l) => l.startsWith("n"));
|
|
3045
|
+
return line ? line.slice(1) : null;
|
|
3046
|
+
} catch {
|
|
3047
|
+
return null;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
return null;
|
|
3051
|
+
}
|
|
3052
|
+
function getProcessEnviron(pid) {
|
|
3053
|
+
try {
|
|
3054
|
+
const raw = readFileSync(`/proc/${pid}/environ`, "utf-8");
|
|
3055
|
+
const env = {};
|
|
3056
|
+
for (const pair of raw.split("\0")) {
|
|
3057
|
+
if (!pair) continue;
|
|
3058
|
+
const eq = pair.indexOf("=");
|
|
3059
|
+
if (eq === -1) continue;
|
|
3060
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
3061
|
+
}
|
|
3062
|
+
return env;
|
|
3063
|
+
} catch {
|
|
3064
|
+
return null;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
function formatProcessState(state) {
|
|
3068
|
+
switch (state) {
|
|
3069
|
+
case "R":
|
|
3070
|
+
return "Running";
|
|
3071
|
+
case "S":
|
|
3072
|
+
return "Sleeping";
|
|
3073
|
+
case "D":
|
|
3074
|
+
return "Disk Sleep";
|
|
3075
|
+
case "Z":
|
|
3076
|
+
return "Zombie";
|
|
3077
|
+
case "T":
|
|
3078
|
+
return "Stopped";
|
|
3079
|
+
case "t":
|
|
3080
|
+
return "Tracing";
|
|
3081
|
+
case "X":
|
|
3082
|
+
return "Dead";
|
|
3083
|
+
case "I":
|
|
3084
|
+
return "Idle";
|
|
3085
|
+
default:
|
|
3086
|
+
return state;
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// src/commands/tracker.ts
|
|
3091
|
+
var RESTART_CAUSE = "fennec:restart";
|
|
3092
|
+
function getFennecDir() {
|
|
3093
|
+
return process.env.FENNEC_DATA_DIR ? resolve2(process.env.FENNEC_DATA_DIR) : resolve2(homedir(), ".fennec");
|
|
3094
|
+
}
|
|
3095
|
+
function getTrackedPath() {
|
|
3096
|
+
return resolve2(getFennecDir(), "tracked.json");
|
|
3097
|
+
}
|
|
3098
|
+
function getSupervisorPidPath() {
|
|
3099
|
+
return resolve2(getFennecDir(), "supervisor.pid");
|
|
3100
|
+
}
|
|
3101
|
+
function logFilePathFor(name) {
|
|
3102
|
+
return resolve2(getFennecDir(), "logs", `${name}.log`);
|
|
3103
|
+
}
|
|
3104
|
+
function readTracked() {
|
|
3105
|
+
try {
|
|
3106
|
+
const path = getTrackedPath();
|
|
3107
|
+
if (!existsSync3(path)) return [];
|
|
3108
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
3109
|
+
} catch {
|
|
3110
|
+
return [];
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
function saveTracked(processes) {
|
|
3114
|
+
try {
|
|
3115
|
+
const path = getTrackedPath();
|
|
3116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3117
|
+
writeFileSync(path, JSON.stringify(processes, null, 2), "utf-8");
|
|
3118
|
+
} catch (err) {
|
|
3119
|
+
console.error("[fennec] Failed to save tracked processes:", err instanceof Error ? err.message : String(err));
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
function addTracked(proc) {
|
|
3123
|
+
const tracked = readTracked();
|
|
3124
|
+
const filtered = tracked.filter((t) => t.name !== proc.name);
|
|
3125
|
+
filtered.push(proc);
|
|
3126
|
+
saveTracked(filtered);
|
|
3127
|
+
}
|
|
3128
|
+
function removeTracked(name) {
|
|
3129
|
+
const tracked = readTracked();
|
|
3130
|
+
saveTracked(tracked.filter((t) => t.name !== name));
|
|
3131
|
+
}
|
|
3132
|
+
function setAutoRestart(name, value) {
|
|
3133
|
+
const tracked = readTracked();
|
|
3134
|
+
let changed = false;
|
|
3135
|
+
for (const t of tracked) {
|
|
3136
|
+
if (t.name === name) {
|
|
3137
|
+
t.autoRestart = value;
|
|
3138
|
+
changed = true;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
if (changed) saveTracked(tracked);
|
|
3142
|
+
}
|
|
3143
|
+
function removeTrackedByPid(pid) {
|
|
3144
|
+
const tracked = readTracked();
|
|
3145
|
+
saveTracked(tracked.filter((t) => t.pid !== pid));
|
|
3146
|
+
}
|
|
3147
|
+
function resolveArgs(proc) {
|
|
3148
|
+
if (proc.args && proc.args.length > 0) return proc.args;
|
|
3149
|
+
return proc.command.split(/\s+/).filter(Boolean);
|
|
3150
|
+
}
|
|
3151
|
+
function adoptProcess(pid, opts = {}) {
|
|
3152
|
+
if (!isProcessRunning(pid)) return null;
|
|
3153
|
+
const command2 = opts.command ?? getProcessCmdline(pid) ?? `pid ${pid}`;
|
|
3154
|
+
const cwd = opts.cwd ?? getProcessCwd(pid) ?? void 0;
|
|
3155
|
+
const name = opts.name ?? `pid_${pid}`;
|
|
3156
|
+
const entry = {
|
|
3157
|
+
name,
|
|
3158
|
+
pid,
|
|
3159
|
+
command: command2,
|
|
3160
|
+
port: opts.port,
|
|
3161
|
+
cwd,
|
|
3162
|
+
env: opts.env,
|
|
3163
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3164
|
+
autoRestart: opts.autoRestart ?? true,
|
|
3165
|
+
flapping: false
|
|
3166
|
+
};
|
|
3167
|
+
addTracked(entry);
|
|
3168
|
+
return entry;
|
|
3169
|
+
}
|
|
3170
|
+
function adoptExternalOnPort(port, name) {
|
|
3171
|
+
const occ = findPidOnPort(port);
|
|
3172
|
+
if (!occ) return null;
|
|
3173
|
+
const tracked = readTracked();
|
|
3174
|
+
if (tracked.some((t) => t.pid === occ.pid)) return null;
|
|
3175
|
+
return adoptProcess(occ.pid, { port, name });
|
|
3176
|
+
}
|
|
3177
|
+
function isTrackedRunning(proc) {
|
|
3178
|
+
if (!isProcessRunning(proc.pid)) return false;
|
|
3179
|
+
const environ = getProcessEnviron(proc.pid);
|
|
3180
|
+
if (environ && "FENNEC_APP_NAME" in environ) {
|
|
3181
|
+
return environ.FENNEC_APP_NAME === proc.name;
|
|
3182
|
+
}
|
|
3183
|
+
const cmdline = getProcessCmdline(proc.pid);
|
|
3184
|
+
if (!cmdline) return true;
|
|
3185
|
+
const argv = resolveArgs(proc);
|
|
3186
|
+
const exe = argv[0];
|
|
3187
|
+
if (!exe) return true;
|
|
3188
|
+
const exeBase = basename(exe);
|
|
3189
|
+
if (exeBase.length === 0) return true;
|
|
3190
|
+
return cmdline.includes(exeBase);
|
|
3191
|
+
}
|
|
3192
|
+
function formatUptime(seconds) {
|
|
3193
|
+
if (seconds < 60) return `${seconds}s`;
|
|
3194
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
3195
|
+
const h = Math.floor(seconds / 3600);
|
|
3196
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
3197
|
+
if (h >= 24) {
|
|
3198
|
+
const d = Math.floor(h / 24);
|
|
3199
|
+
return `${d}d ${h % 24}h ${m}m`;
|
|
3200
|
+
}
|
|
3201
|
+
return `${h}h ${m}m`;
|
|
3202
|
+
}
|
|
3203
|
+
var DEFAULT_MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
3204
|
+
var DEFAULT_MAX_LOG_FILES = 3;
|
|
3205
|
+
function rotateLogFile(filePath, maxSize = DEFAULT_MAX_LOG_SIZE, maxFiles = DEFAULT_MAX_LOG_FILES) {
|
|
3206
|
+
try {
|
|
3207
|
+
if (!existsSync3(filePath)) return false;
|
|
3208
|
+
const stats = statSync(filePath);
|
|
3209
|
+
if (stats.size < maxSize) return false;
|
|
3210
|
+
const dir = dirname(filePath);
|
|
3211
|
+
const base = basename(filePath);
|
|
3212
|
+
const oldestPath = resolve2(dir, `${base}.${maxFiles}`);
|
|
3213
|
+
if (existsSync3(oldestPath)) {
|
|
3214
|
+
try {
|
|
3215
|
+
rmSync(oldestPath);
|
|
3216
|
+
} catch {
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
3220
|
+
const src = resolve2(dir, `${base}.${i}`);
|
|
3221
|
+
const dst = resolve2(dir, `${base}.${i + 1}`);
|
|
3222
|
+
if (existsSync3(src)) {
|
|
3223
|
+
try {
|
|
3224
|
+
renameSync(src, dst);
|
|
3225
|
+
} catch {
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
try {
|
|
3230
|
+
renameSync(filePath, resolve2(dir, `${base}.1`));
|
|
3231
|
+
} catch {
|
|
3232
|
+
}
|
|
3233
|
+
return true;
|
|
3234
|
+
} catch (err) {
|
|
3235
|
+
console.error("[fennec] Log rotation failed:", err instanceof Error ? err.message : String(err));
|
|
3236
|
+
return false;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
function buildSpawnEnv(stored) {
|
|
3240
|
+
const merged = { ...process.env };
|
|
3241
|
+
for (const [k, v] of Object.entries(stored ?? {})) {
|
|
3242
|
+
if (v !== void 0) merged[k] = v;
|
|
3243
|
+
}
|
|
3244
|
+
if (process.env.FENNEC_DATA_DIR) merged.FENNEC_DATA_DIR = process.env.FENNEC_DATA_DIR;
|
|
3245
|
+
if (process.env.HOME) merged.HOME = process.env.HOME;
|
|
3246
|
+
return merged;
|
|
3247
|
+
}
|
|
3248
|
+
function respawnTracked(proc, cause) {
|
|
3249
|
+
const cmdParts = resolveArgs(proc);
|
|
3250
|
+
const logFilePath = logFilePathFor(proc.name);
|
|
3251
|
+
mkdirSync(dirname(logFilePath), { recursive: true });
|
|
3252
|
+
if (cause) annotateRestart(logFilePath, cause);
|
|
3253
|
+
if (isProcessRunning(proc.pid)) {
|
|
3254
|
+
try {
|
|
3255
|
+
process.kill(proc.pid, "SIGTERM");
|
|
3256
|
+
} catch {
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
const child = spawnDaemon({
|
|
3260
|
+
cmdParts,
|
|
3261
|
+
name: proc.name,
|
|
3262
|
+
cwd: proc.cwd,
|
|
3263
|
+
logFilePath,
|
|
3264
|
+
env: buildSpawnEnv(proc.env),
|
|
3265
|
+
logMode: proc.logMode
|
|
3266
|
+
});
|
|
3267
|
+
const pid = child.pid ?? 0;
|
|
3268
|
+
addTracked({
|
|
3269
|
+
...proc,
|
|
3270
|
+
pid,
|
|
3271
|
+
args: cmdParts,
|
|
3272
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3273
|
+
restartCause: cause
|
|
3274
|
+
});
|
|
3275
|
+
return pid;
|
|
3276
|
+
}
|
|
3277
|
+
function spawnDaemon(opts) {
|
|
3278
|
+
rotateLogFile(opts.logFilePath);
|
|
3279
|
+
const env = { ...opts.env ?? buildSpawnEnv(), FENNEC_APP_NAME: opts.name };
|
|
3280
|
+
if (opts.logMode === "jsonl") {
|
|
3281
|
+
return spawnDaemonJsonl(opts, env);
|
|
3282
|
+
}
|
|
3283
|
+
const fd = openSync(opts.logFilePath, "a");
|
|
3284
|
+
const child = spawn(opts.cmdParts[0], opts.cmdParts.slice(1), {
|
|
3285
|
+
cwd: opts.cwd,
|
|
3286
|
+
env,
|
|
3287
|
+
stdio: ["ignore", fd, fd],
|
|
3288
|
+
detached: true
|
|
3289
|
+
});
|
|
3290
|
+
child.unref();
|
|
3291
|
+
child.once("spawn", () => {
|
|
3292
|
+
try {
|
|
3293
|
+
closeSync(fd);
|
|
3294
|
+
} catch {
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
return child;
|
|
3298
|
+
}
|
|
3299
|
+
function spawnDaemonJsonl(opts, env) {
|
|
3300
|
+
const relay = spawn(
|
|
3301
|
+
process.execPath,
|
|
3302
|
+
["-e", JSONL_RELAY],
|
|
3303
|
+
{
|
|
3304
|
+
env: { ...process.env, FENNEC_RELAY_LOG: opts.logFilePath },
|
|
3305
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
3306
|
+
detached: true
|
|
3307
|
+
}
|
|
3308
|
+
);
|
|
3309
|
+
relay.unref();
|
|
3310
|
+
const child = spawn(opts.cmdParts[0], opts.cmdParts.slice(1), {
|
|
3311
|
+
cwd: opts.cwd,
|
|
3312
|
+
env,
|
|
3313
|
+
stdio: ["ignore", relay.stdin, relay.stdin],
|
|
3314
|
+
detached: true
|
|
3315
|
+
});
|
|
3316
|
+
child.unref();
|
|
3317
|
+
try {
|
|
3318
|
+
relay.stdin?.destroy();
|
|
3319
|
+
} catch {
|
|
3320
|
+
}
|
|
3321
|
+
return child;
|
|
3322
|
+
}
|
|
3323
|
+
var JSONL_RELAY = `
|
|
3324
|
+
const fs = require('fs');
|
|
3325
|
+
const fd = fs.openSync(process.env.FENNEC_RELAY_LOG, 'a');
|
|
3326
|
+
let buf = '';
|
|
3327
|
+
const SECRET = new RegExp('(password|passwd|token|secret|api[_-]?key|access[_-]?key).{0,60}', 'gi');
|
|
3328
|
+
function redact(s){ return s.replace(SECRET, '$1***'); }
|
|
3329
|
+
function level(s){
|
|
3330
|
+
const t = s.toLowerCase();
|
|
3331
|
+
if (t.indexOf('error')>=0 || t.indexOf('fail')>=0 || t.indexOf('fatal')>=0 || t.indexOf('exception')>=0 || t.indexOf('denied')>=0 || t.indexOf('refused')>=0 || t.indexOf('cannot')>=0 || t.indexOf('panic')>=0) return 'error';
|
|
3332
|
+
if (t.indexOf('warn')>=0) return 'warn';
|
|
3333
|
+
return 'info';
|
|
3334
|
+
}
|
|
3335
|
+
function flush(){
|
|
3336
|
+
const parts = buf.split('\\n');
|
|
3337
|
+
buf = parts.pop() || '';
|
|
3338
|
+
for (const r of parts){
|
|
3339
|
+
if (!r) continue;
|
|
3340
|
+
const line = redact(r);
|
|
3341
|
+
try { fs.writeSync(fd, JSON.stringify({ ts: new Date().toISOString(), level: level(line), source: 'app', line }) + '\\n'); } catch (e) {}
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
process.stdin.on('data', (c) => { buf += c.toString('utf8'); flush(); });
|
|
3345
|
+
process.stdin.on('end', () => { flush(); try { fs.closeSync(fd); } catch (e) {} process.exit(0); });
|
|
3346
|
+
process.stdin.on('error', () => { process.exit(0); });
|
|
3347
|
+
`;
|
|
3348
|
+
function annotateRestart(logFilePath, cause) {
|
|
3349
|
+
try {
|
|
3350
|
+
const fd = openSync(logFilePath, "a");
|
|
3351
|
+
try {
|
|
3352
|
+
writeSync(fd, `
|
|
3353
|
+
${RESTART_CAUSE} cause=${cause} at=${(/* @__PURE__ */ new Date()).toISOString()}
|
|
3354
|
+
`);
|
|
3355
|
+
} finally {
|
|
3356
|
+
closeSync(fd);
|
|
3357
|
+
}
|
|
3358
|
+
} catch {
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
// src/commands/supervisor.ts
|
|
3363
|
+
import pc4 from "picocolors";
|
|
3364
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2, openSync as openSync2, closeSync as closeSync2 } from "fs";
|
|
3365
|
+
import { spawn as spawn2 } from "child_process";
|
|
3366
|
+
import { dirname as dirname2 } from "path";
|
|
3367
|
+
var POLL_INTERVAL_MS = 3e3;
|
|
3368
|
+
var CRASH_WINDOW_MS = 6e4;
|
|
3369
|
+
var MAX_RESTARTS_IN_WINDOW = 5;
|
|
3370
|
+
var FLAPPING_THRESHOLD = 3;
|
|
3371
|
+
var PORT_GRACE_MS = 15e3;
|
|
3372
|
+
var LOG_ROTATE_CHECK_MS = 60 * 60 * 1e3;
|
|
3373
|
+
function getSupervisorPid() {
|
|
3374
|
+
const path = getSupervisorPidPath();
|
|
3375
|
+
if (!existsSync4(path)) return null;
|
|
3376
|
+
const pid = parseInt(readFileSync3(path, "utf-8").trim(), 10);
|
|
3377
|
+
if (isNaN(pid)) return null;
|
|
3378
|
+
return isProcessRunning(pid) ? pid : null;
|
|
3379
|
+
}
|
|
3380
|
+
function ensureSupervisorRunning() {
|
|
3381
|
+
const existing = getSupervisorPid();
|
|
3382
|
+
if (existing) return existing;
|
|
3383
|
+
return startSupervisorDaemon();
|
|
3384
|
+
}
|
|
3385
|
+
function startSupervisorDaemon() {
|
|
3386
|
+
const logFilePath = logFilePathFor("supervisor");
|
|
3387
|
+
mkdirSync2(dirname2(logFilePath), { recursive: true });
|
|
3388
|
+
const fd = openSync2(logFilePath, "a");
|
|
3389
|
+
const child = spawn2(process.execPath, [process.argv[1], "__supervisor"], {
|
|
3390
|
+
env: { ...process.env },
|
|
3391
|
+
stdio: ["ignore", fd, fd],
|
|
3392
|
+
detached: true
|
|
3393
|
+
});
|
|
3394
|
+
child.unref();
|
|
3395
|
+
child.once("spawn", () => {
|
|
3396
|
+
try {
|
|
3397
|
+
closeSync2(fd);
|
|
3398
|
+
} catch {
|
|
3399
|
+
}
|
|
3400
|
+
});
|
|
3401
|
+
return child.pid ?? 0;
|
|
3402
|
+
}
|
|
3403
|
+
async function supervisorCommand(args2) {
|
|
3404
|
+
const sub = args2[0] ?? "status";
|
|
3405
|
+
switch (sub) {
|
|
3406
|
+
case "start":
|
|
3407
|
+
return supervisorStart();
|
|
3408
|
+
case "stop":
|
|
3409
|
+
return supervisorStop();
|
|
3410
|
+
case "restart":
|
|
3411
|
+
await supervisorStop();
|
|
3412
|
+
return supervisorStart();
|
|
3413
|
+
case "status":
|
|
3414
|
+
return supervisorStatus();
|
|
3415
|
+
default:
|
|
3416
|
+
console.error(renderError("Unknown sub-command", `"${sub}" \u2014 use: start | stop | status | restart`));
|
|
3417
|
+
process.exit(1);
|
|
2566
3418
|
}
|
|
2567
3419
|
}
|
|
2568
|
-
function
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
} catch {
|
|
3420
|
+
function supervisorStart() {
|
|
3421
|
+
const existing = getSupervisorPid();
|
|
3422
|
+
if (existing) {
|
|
3423
|
+
console.error(`
|
|
3424
|
+
${pc4.yellow("\u26A0")} Supervisor already running ${pc4.dim(`(PID ${existing})`)}
|
|
3425
|
+
`);
|
|
3426
|
+
return;
|
|
2576
3427
|
}
|
|
2577
|
-
|
|
3428
|
+
const pid = startSupervisorDaemon();
|
|
3429
|
+
console.error(`
|
|
3430
|
+
${pc4.green("\u2713")} ${pc4.bold("Supervisor started")} ${pc4.dim(`(PID ${pid})`)}`);
|
|
3431
|
+
console.error(` ${renderKV("Logs", pc4.cyan("fennec log supervisor"))}`);
|
|
3432
|
+
console.error(` ${pc4.dim("It will auto-restart apps started with")} ${pc4.cyan("--restart")} ${pc4.dim("even if you close this terminal.")}
|
|
3433
|
+
`);
|
|
2578
3434
|
}
|
|
2579
|
-
function
|
|
3435
|
+
function supervisorStop() {
|
|
3436
|
+
const pid = getSupervisorPid();
|
|
3437
|
+
if (!pid) {
|
|
3438
|
+
console.error(`
|
|
3439
|
+
${pc4.dim("Supervisor is not running.")}
|
|
3440
|
+
`);
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
2580
3443
|
try {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
} catch {
|
|
3444
|
+
process.kill(pid, "SIGTERM");
|
|
3445
|
+
console.error(`
|
|
3446
|
+
${pc4.green("\u2713")} ${pc4.bold("Supervisor stopped")} ${pc4.dim(`(PID ${pid})`)}
|
|
3447
|
+
`);
|
|
3448
|
+
} catch (err) {
|
|
3449
|
+
console.error(renderError("Failed to stop supervisor", String(err)));
|
|
2586
3450
|
}
|
|
2587
|
-
return 0;
|
|
2588
|
-
}
|
|
2589
|
-
function formatStartTime(starttime, uptimeSeconds, hertz) {
|
|
2590
3451
|
try {
|
|
2591
|
-
|
|
2592
|
-
const processStart = bootTime + starttime / hertz;
|
|
2593
|
-
return new Date(processStart * 1e3).toISOString();
|
|
3452
|
+
unlinkSync(getSupervisorPidPath());
|
|
2594
3453
|
} catch {
|
|
2595
|
-
return null;
|
|
2596
3454
|
}
|
|
2597
3455
|
}
|
|
2598
|
-
function
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
3456
|
+
function supervisorStatus() {
|
|
3457
|
+
const pid = getSupervisorPid();
|
|
3458
|
+
const tracked = readTracked();
|
|
3459
|
+
const managed = tracked.filter((t) => t.autoRestart);
|
|
3460
|
+
console.error(`
|
|
3461
|
+
${symbols.fox} ${pc4.bold("Fennec Supervisor")}`);
|
|
3462
|
+
if (pid) {
|
|
3463
|
+
console.error(` ${pc4.green("\u25CF")} running ${pc4.dim(`(PID ${pid})`)}`);
|
|
2602
3464
|
} else {
|
|
2603
|
-
|
|
3465
|
+
console.error(` ${pc4.red("\u25CB")} not running ${pc4.dim("\u2014 start with")} ${pc4.cyan("fennec supervisor start")}`);
|
|
2604
3466
|
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
);
|
|
3467
|
+
console.error(`
|
|
3468
|
+
${pc4.bold("Managed apps")} ${pc4.dim(`(${managed.length} with auto-restart)`)}`);
|
|
3469
|
+
if (managed.length === 0) {
|
|
3470
|
+
console.error(` ${pc4.dim("None. Start one with")} ${pc4.cyan("fennec start <cmd> --name <name> --restart")}`);
|
|
3471
|
+
} else {
|
|
3472
|
+
for (const t of managed) {
|
|
3473
|
+
const running = isTrackedRunning(t);
|
|
3474
|
+
const dot = running ? pc4.green("\u25CF") : pc4.red("\u25CB");
|
|
3475
|
+
const state = running ? pc4.green(`running (PID ${t.pid})`) : pc4.red("stopped");
|
|
3476
|
+
console.error(` ${dot} ${pc4.bold(t.name)} ${pc4.dim("\u2014")} ${state}`);
|
|
2614
3477
|
}
|
|
2615
|
-
|
|
2616
|
-
|
|
3478
|
+
}
|
|
3479
|
+
console.error();
|
|
3480
|
+
}
|
|
3481
|
+
var PORT_FAIL_THRESHOLD = 2;
|
|
3482
|
+
async function runSupervisor() {
|
|
3483
|
+
const pidPath = getSupervisorPidPath();
|
|
3484
|
+
mkdirSync2(dirname2(pidPath), { recursive: true });
|
|
3485
|
+
const existing = getSupervisorPid();
|
|
3486
|
+
if (existing && existing !== process.pid) {
|
|
3487
|
+
log(`supervisor already running (PID ${existing}); exiting`);
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
writeFileSync2(pidPath, String(process.pid), "utf-8");
|
|
3491
|
+
log(`supervisor started (PID ${process.pid}), polling every ${POLL_INTERVAL_MS}ms`);
|
|
3492
|
+
const crashes = /* @__PURE__ */ new Map();
|
|
3493
|
+
let stopped = false;
|
|
3494
|
+
const cleanup = () => {
|
|
3495
|
+
stopped = true;
|
|
3496
|
+
try {
|
|
3497
|
+
unlinkSync(pidPath);
|
|
3498
|
+
} catch {
|
|
2617
3499
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
3500
|
+
log("supervisor stopping");
|
|
3501
|
+
process.exit(0);
|
|
3502
|
+
};
|
|
3503
|
+
process.on("SIGTERM", cleanup);
|
|
3504
|
+
process.on("SIGINT", cleanup);
|
|
3505
|
+
const tick = async () => {
|
|
3506
|
+
if (stopped) return;
|
|
3507
|
+
let tracked;
|
|
3508
|
+
try {
|
|
3509
|
+
tracked = readTracked();
|
|
3510
|
+
} catch (err) {
|
|
3511
|
+
log(`failed to read tracked.json: ${String(err)}`);
|
|
3512
|
+
return;
|
|
2620
3513
|
}
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
3514
|
+
for (const t of tracked) {
|
|
3515
|
+
if (!t.autoRestart) continue;
|
|
3516
|
+
if (t.port && isTrackedRunning(t)) {
|
|
3517
|
+
const startedAt = Date.parse(t.startedAt);
|
|
3518
|
+
const ageMs = Number.isNaN(startedAt) ? Number.POSITIVE_INFINITY : Date.now() - startedAt;
|
|
3519
|
+
if (ageMs >= PORT_GRACE_MS) {
|
|
3520
|
+
let rec2 = crashes.get(t.name);
|
|
3521
|
+
if (!rec2) {
|
|
3522
|
+
rec2 = { windowStart: Date.now(), count: 0, gaveUp: false, portFails: 0 };
|
|
3523
|
+
crashes.set(t.name, rec2);
|
|
3524
|
+
}
|
|
3525
|
+
let listening = false;
|
|
3526
|
+
const healthUrl = t.healthCheck ? resolveHealthUrl(t.healthCheck, t.port) : null;
|
|
3527
|
+
try {
|
|
3528
|
+
listening = healthUrl ? await checkHttp(healthUrl) : await checkPort(t.port);
|
|
3529
|
+
} catch {
|
|
3530
|
+
listening = false;
|
|
3531
|
+
}
|
|
3532
|
+
if (listening) {
|
|
3533
|
+
rec2.portFails = 0;
|
|
3534
|
+
if (t.flapping && Date.now() - rec2.windowStart > CRASH_WINDOW_MS) setFlapping(t, false);
|
|
3535
|
+
} else {
|
|
3536
|
+
rec2.portFails++;
|
|
3537
|
+
const probe = healthUrl ? `health check ${healthUrl}` : `port ${t.port}`;
|
|
3538
|
+
if (rec2.portFails >= PORT_FAIL_THRESHOLD) {
|
|
3539
|
+
rec2.portFails = 0;
|
|
3540
|
+
rec2.count++;
|
|
3541
|
+
if (rec2.count >= FLAPPING_THRESHOLD) setFlapping(t, true);
|
|
3542
|
+
log(`${t.name}: alive but ${probe} not responding ${PORT_FAIL_THRESHOLD}x \u2014 restarting`);
|
|
3543
|
+
try {
|
|
3544
|
+
const newPid = respawnTracked(t, "port-down");
|
|
3545
|
+
log(`${t.name}: restarted (PID ${newPid}) after ${probe} failure`);
|
|
3546
|
+
} catch (err) {
|
|
3547
|
+
log(`${t.name}: restart failed: ${String(err)}`);
|
|
3548
|
+
}
|
|
3549
|
+
} else {
|
|
3550
|
+
log(`${t.name}: ${probe} not responding (${rec2.portFails}/${PORT_FAIL_THRESHOLD})`);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
2634
3553
|
}
|
|
2635
|
-
|
|
3554
|
+
continue;
|
|
3555
|
+
}
|
|
3556
|
+
if (isTrackedRunning(t)) continue;
|
|
3557
|
+
const now = Date.now();
|
|
3558
|
+
let rec = crashes.get(t.name);
|
|
3559
|
+
if (rec?.gaveUp && now - rec.windowStart > CRASH_WINDOW_MS) rec = void 0;
|
|
3560
|
+
if (!rec || now - rec.windowStart > CRASH_WINDOW_MS) {
|
|
3561
|
+
rec = { windowStart: now, count: 0, gaveUp: false, portFails: 0 };
|
|
3562
|
+
crashes.set(t.name, rec);
|
|
3563
|
+
if (t.flapping) setFlapping(t, false);
|
|
3564
|
+
}
|
|
3565
|
+
if (rec.gaveUp) continue;
|
|
3566
|
+
if (rec.count >= MAX_RESTARTS_IN_WINDOW) {
|
|
3567
|
+
rec.gaveUp = true;
|
|
3568
|
+
log(`${t.name}: crashed ${rec.count}x in <60s \u2014 giving up (will retry after window resets)`);
|
|
3569
|
+
continue;
|
|
3570
|
+
}
|
|
3571
|
+
rec.count++;
|
|
3572
|
+
if (rec.count >= FLAPPING_THRESHOLD) setFlapping(t, true);
|
|
3573
|
+
try {
|
|
3574
|
+
const newPid = respawnTracked(t, "crash");
|
|
3575
|
+
log(`${t.name}: restarted (PID ${newPid}) [${rec.count}/${MAX_RESTARTS_IN_WINDOW} in window]`);
|
|
3576
|
+
} catch (err) {
|
|
3577
|
+
log(`${t.name}: restart failed: ${String(err)}`);
|
|
3578
|
+
}
|
|
2636
3579
|
}
|
|
2637
|
-
|
|
2638
|
-
|
|
3580
|
+
};
|
|
3581
|
+
tick();
|
|
3582
|
+
setInterval(tick, POLL_INTERVAL_MS);
|
|
3583
|
+
setInterval(rotateAllLogs, LOG_ROTATE_CHECK_MS);
|
|
3584
|
+
await new Promise(() => {
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
function rotateAllLogs() {
|
|
3588
|
+
try {
|
|
3589
|
+
for (const t of readTracked()) {
|
|
3590
|
+
try {
|
|
3591
|
+
rotateLogFile(logFilePathFor(t.name));
|
|
3592
|
+
} catch {
|
|
3593
|
+
}
|
|
2639
3594
|
}
|
|
3595
|
+
} catch (err) {
|
|
3596
|
+
log(`log rotation pass failed: ${String(err)}`);
|
|
2640
3597
|
}
|
|
2641
|
-
return processes;
|
|
2642
3598
|
}
|
|
2643
|
-
function
|
|
3599
|
+
function setFlapping(t, val) {
|
|
3600
|
+
if (!!t.flapping === val) return;
|
|
3601
|
+
t.flapping = val;
|
|
2644
3602
|
try {
|
|
2645
|
-
|
|
2646
|
-
return true;
|
|
3603
|
+
addTracked({ ...t, flapping: val });
|
|
2647
3604
|
} catch {
|
|
2648
|
-
return false;
|
|
2649
3605
|
}
|
|
2650
3606
|
}
|
|
2651
|
-
function
|
|
3607
|
+
function log(msg) {
|
|
3608
|
+
process.stdout.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
|
|
3609
|
+
`);
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
// src/commands/persist.ts
|
|
3613
|
+
import pc5 from "picocolors";
|
|
3614
|
+
import { existsSync as existsSync5, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
3615
|
+
import { resolve as resolve3, dirname as dirname3 } from "path";
|
|
3616
|
+
import { homedir as homedir2, platform } from "os";
|
|
3617
|
+
import { execFileSync } from "child_process";
|
|
3618
|
+
function detectBackend() {
|
|
3619
|
+
const p = platform();
|
|
3620
|
+
if (p === "linux" || p === "freebsd") {
|
|
3621
|
+
return "systemd";
|
|
3622
|
+
}
|
|
3623
|
+
if (p === "darwin") return "launchd";
|
|
3624
|
+
if (p === "win32") return "windows";
|
|
3625
|
+
return "unsupported";
|
|
3626
|
+
}
|
|
3627
|
+
function unitPaths(backend) {
|
|
3628
|
+
const fennecDir = getFennecDir();
|
|
3629
|
+
switch (backend) {
|
|
3630
|
+
case "systemd":
|
|
3631
|
+
return { unit: resolve3(homedir2(), ".config", "systemd", "user", "fennec-supervisor.service") };
|
|
3632
|
+
case "launchd":
|
|
3633
|
+
return { unit: resolve3(homedir2(), "Library", "LaunchAgents", "io.fennec.supervisor.plist") };
|
|
3634
|
+
case "windows":
|
|
3635
|
+
return { unit: resolve3(homedir2(), "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "fennec-supervisor.bat") };
|
|
3636
|
+
default:
|
|
3637
|
+
return { unit: resolve3(fennecDir, "persist.unit") };
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
function buildUnit(backend, dataDir) {
|
|
3641
|
+
const nodeBin = process.execPath;
|
|
3642
|
+
const cli = process.argv[1] ?? "fennec";
|
|
3643
|
+
const envLine = `FENNEC_DATA_DIR=${dataDir}`;
|
|
3644
|
+
if (backend === "systemd") {
|
|
3645
|
+
return `[Unit]
|
|
3646
|
+
Description=Fennec process supervisor (auto-restarts managed apps)
|
|
3647
|
+
After=network.target
|
|
3648
|
+
|
|
3649
|
+
[Service]
|
|
3650
|
+
Type=simple
|
|
3651
|
+
Environment=${envLine}
|
|
3652
|
+
ExecStart=${nodeBin} ${cli} supervisor start
|
|
3653
|
+
Restart=on-failure
|
|
3654
|
+
|
|
3655
|
+
[Install]
|
|
3656
|
+
WantedBy=default.target
|
|
3657
|
+
`;
|
|
3658
|
+
}
|
|
3659
|
+
if (backend === "launchd") {
|
|
3660
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3661
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3662
|
+
<plist version="1.0">
|
|
3663
|
+
<dict>
|
|
3664
|
+
<key>Label</key>
|
|
3665
|
+
<string>io.fennec.supervisor</string>
|
|
3666
|
+
<key>ProgramArguments</key>
|
|
3667
|
+
<array>
|
|
3668
|
+
<string>${nodeBin}</string>
|
|
3669
|
+
<string>${cli}</string>
|
|
3670
|
+
<string>supervisor</string>
|
|
3671
|
+
<string>start</string>
|
|
3672
|
+
</array>
|
|
3673
|
+
<key>EnvironmentVariables</key>
|
|
3674
|
+
<dict>
|
|
3675
|
+
<key>FENNEC_DATA_DIR</key>
|
|
3676
|
+
<string>${dataDir}</string>
|
|
3677
|
+
</dict>
|
|
3678
|
+
<key>RunAtLoad</key>
|
|
3679
|
+
<true/>
|
|
3680
|
+
<key>KeepAlive</key>
|
|
3681
|
+
<false/>
|
|
3682
|
+
</dict>
|
|
3683
|
+
</plist>
|
|
3684
|
+
`;
|
|
3685
|
+
}
|
|
3686
|
+
return `@echo off
|
|
3687
|
+
set FENNEC_DATA_DIR=${dataDir}
|
|
3688
|
+
"${nodeBin}" "${cli}" supervisor start
|
|
3689
|
+
`;
|
|
3690
|
+
}
|
|
3691
|
+
function run(cmd, args2) {
|
|
2652
3692
|
try {
|
|
2653
|
-
|
|
2654
|
-
return true;
|
|
2655
|
-
} catch {
|
|
2656
|
-
|
|
3693
|
+
const out = execFileSync(cmd, args2, { stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
|
|
3694
|
+
return { ok: true, out };
|
|
3695
|
+
} catch (err) {
|
|
3696
|
+
const e = err;
|
|
3697
|
+
const out = (e.stdout ?? e.stderr ?? Buffer.from("")).toString().trim();
|
|
3698
|
+
return { ok: false, out };
|
|
2657
3699
|
}
|
|
2658
3700
|
}
|
|
2659
|
-
function
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3701
|
+
function persistEnabled(backend = detectBackend()) {
|
|
3702
|
+
const { unit } = unitPaths(backend);
|
|
3703
|
+
return existsSync5(unit);
|
|
3704
|
+
}
|
|
3705
|
+
function ensurePersistEnabled() {
|
|
3706
|
+
const backend = detectBackend();
|
|
3707
|
+
if (backend === "unsupported") return false;
|
|
3708
|
+
if (persistEnabled(backend)) return false;
|
|
3709
|
+
persistEnable(backend);
|
|
3710
|
+
return true;
|
|
3711
|
+
}
|
|
3712
|
+
async function persistCommand(args2) {
|
|
3713
|
+
const sub = args2[0] ?? "status";
|
|
3714
|
+
const backend = detectBackend();
|
|
3715
|
+
if (backend === "unsupported") {
|
|
3716
|
+
console.error(renderError("Unsupported platform", `Boot persistence isn't available on ${platform()}. You can still run "fennec supervisor start" manually.`));
|
|
3717
|
+
process.exit(1);
|
|
3718
|
+
}
|
|
3719
|
+
switch (sub) {
|
|
3720
|
+
case "enable":
|
|
3721
|
+
return persistEnable(backend);
|
|
3722
|
+
case "disable":
|
|
3723
|
+
return persistDisable(backend);
|
|
3724
|
+
case "status":
|
|
3725
|
+
return persistStatus(backend);
|
|
2677
3726
|
default:
|
|
2678
|
-
|
|
3727
|
+
console.error(renderError("Unknown sub-command", `"${sub}" \u2014 use: enable | disable | status`));
|
|
3728
|
+
process.exit(1);
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
function persistEnable(backend) {
|
|
3732
|
+
const dataDir = getFennecDir();
|
|
3733
|
+
const { unit } = unitPaths(backend);
|
|
3734
|
+
mkdirSync3(dirname3(unit), { recursive: true });
|
|
3735
|
+
writeFileSync3(unit, buildUnit(backend, dataDir), "utf-8");
|
|
3736
|
+
if (backend === "windows") {
|
|
3737
|
+
console.error(`
|
|
3738
|
+
${pc5.green("\u2713")} ${pc5.bold("Boot script installed")} ${pc5.dim(`(runs at next login)`)}`);
|
|
3739
|
+
console.error(` ${renderKV("Path", pc5.cyan(unit))}`);
|
|
3740
|
+
console.error(` ${pc5.dim("Supervisor will start automatically when you log in.")}
|
|
3741
|
+
`);
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
if (backend === "systemd") {
|
|
3745
|
+
const reg = run("systemctl", ["--user", "enable", "--now", resolve3(unit)]);
|
|
3746
|
+
if (!reg.ok) {
|
|
3747
|
+
run("systemctl", ["--user", "enable", resolve3(unit)]);
|
|
3748
|
+
run("systemctl", ["--user", "start", "fennec-supervisor.service"]);
|
|
3749
|
+
}
|
|
3750
|
+
const linger = run("loginctl", ["enable-linger"]);
|
|
3751
|
+
if (!linger.ok) {
|
|
3752
|
+
console.error(` ${pc5.yellow("\u26A0")} ${pc5.dim("Could not enable user linger \u2014 the supervisor may stop when you log out.")}`);
|
|
3753
|
+
}
|
|
3754
|
+
console.error(`
|
|
3755
|
+
${pc5.green("\u2713")} ${pc5.bold("Boot persistence enabled")} ${pc5.dim("(systemd user service)")}`);
|
|
3756
|
+
console.error(` ${renderKV("Unit", pc5.cyan("fennec-supervisor.service"))}`);
|
|
3757
|
+
console.error(` ${renderKV("Data dir", pc5.cyan(dataDir))}`);
|
|
3758
|
+
console.error(` ${pc5.dim("The supervisor (and all --restart apps) will start at login and keep running after logout.")}
|
|
3759
|
+
`);
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3762
|
+
if (backend === "launchd") {
|
|
3763
|
+
run("launchctl", ["load", resolve3(unit)]);
|
|
3764
|
+
console.error(`
|
|
3765
|
+
${pc5.green("\u2713")} ${pc5.bold("Boot persistence enabled")} ${pc5.dim("(launchd agent)")}`);
|
|
3766
|
+
console.error(` ${renderKV("Label", pc5.cyan("io.fennec.supervisor"))}`);
|
|
3767
|
+
console.error(` ${renderKV("Data dir", pc5.cyan(dataDir))}`);
|
|
3768
|
+
console.error(` ${pc5.dim("The supervisor will start at login.")}
|
|
3769
|
+
`);
|
|
3770
|
+
return;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
function persistDisable(backend) {
|
|
3774
|
+
const { unit } = unitPaths(backend);
|
|
3775
|
+
if (backend === "systemd") {
|
|
3776
|
+
run("systemctl", ["--user", "disable", "--now", "fennec-supervisor.service"]);
|
|
3777
|
+
} else if (backend === "launchd") {
|
|
3778
|
+
run("launchctl", ["unload", resolve3(unit)]);
|
|
3779
|
+
}
|
|
3780
|
+
try {
|
|
3781
|
+
if (existsSync5(unit)) unlinkSync2(unit);
|
|
3782
|
+
} catch {
|
|
3783
|
+
}
|
|
3784
|
+
console.error(`
|
|
3785
|
+
${pc5.green("\u2713")} ${pc5.bold("Boot persistence disabled")}
|
|
3786
|
+
`);
|
|
3787
|
+
}
|
|
3788
|
+
function persistStatus(backend) {
|
|
3789
|
+
const { unit } = unitPaths(backend);
|
|
3790
|
+
const installed = existsSync5(unit);
|
|
3791
|
+
const label = backend === "systemd" ? "fennec-supervisor.service" : backend === "launchd" ? "io.fennec.supervisor" : "fennec-supervisor.bat";
|
|
3792
|
+
console.error(`
|
|
3793
|
+
${pc5.bold("Fennec Boot Persistence")} ${pc5.dim(`(${backend})`)}`);
|
|
3794
|
+
if (!installed) {
|
|
3795
|
+
console.error(` ${pc5.red("\u25CB")} ${pc5.dim("not installed \u2014 run ")}${pc5.cyan("fennec persist enable")}${pc5.dim(" to auto-start apps after reboot")}`);
|
|
3796
|
+
console.error();
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
if (backend === "systemd") {
|
|
3800
|
+
const en = run("systemctl", ["--user", "is-enabled", "fennec-supervisor.service"]).out;
|
|
3801
|
+
const ac = run("systemctl", ["--user", "is-active", "fennec-supervisor.service"]).out;
|
|
3802
|
+
console.error(` ${pc5.green("\u25CF")} ${pc5.bold("installed")}`);
|
|
3803
|
+
console.error(` ${renderKV("Enabled", en || "?")}`);
|
|
3804
|
+
console.error(` ${renderKV("Active", ac || "?")}`);
|
|
3805
|
+
} else if (backend === "launchd") {
|
|
3806
|
+
const loaded = run("launchctl", ["list", "io.fennec.supervisor"]).ok;
|
|
3807
|
+
console.error(` ${pc5.green("\u25CF")} ${pc5.bold("installed")}`);
|
|
3808
|
+
console.error(` ${renderKV("Loaded", loaded ? pc5.green("yes") : pc5.red("no"))}`);
|
|
3809
|
+
} else {
|
|
3810
|
+
console.error(` ${pc5.green("\u25CF")} ${pc5.bold("installed")} ${pc5.dim("(runs at login)")}`);
|
|
2679
3811
|
}
|
|
3812
|
+
console.error(` ${renderKV("Unit", pc5.cyan(label))}`);
|
|
3813
|
+
console.error(` ${renderKV("Path", pc5.cyan(unit))}`);
|
|
3814
|
+
console.error();
|
|
2680
3815
|
}
|
|
2681
3816
|
|
|
2682
3817
|
// src/commands/ps.ts
|
|
2683
|
-
import
|
|
3818
|
+
import pc6 from "picocolors";
|
|
2684
3819
|
async function psCommand(args2) {
|
|
2685
3820
|
const watchFlag = args2.includes("-w") || args2.includes("--watch");
|
|
2686
3821
|
const systemFlag = args2.includes("--system") || args2.includes("-a") || args2.includes("--all");
|
|
@@ -2703,32 +3838,32 @@ async function psCommand(args2) {
|
|
|
2703
3838
|
process.stdout.write("\r\x1B[K");
|
|
2704
3839
|
if (processes.length === 0) {
|
|
2705
3840
|
console.error(`
|
|
2706
|
-
${
|
|
3841
|
+
${pc6.dim("No system processes found.")}
|
|
2707
3842
|
`);
|
|
2708
3843
|
return;
|
|
2709
3844
|
}
|
|
2710
3845
|
const columns2 = [
|
|
2711
|
-
{ key: "pid", label: "PID", align: "right", format: (v) =>
|
|
2712
|
-
{ key: "name", label: "Name", format: (v) =>
|
|
3846
|
+
{ key: "pid", label: "PID", align: "right", format: (v) => pc6.dim(String(v).padStart(6)) },
|
|
3847
|
+
{ key: "name", label: "Name", format: (v) => pc6.bold(String(v)) },
|
|
2713
3848
|
{ key: "cpu", label: "CPU%", align: "right", format: (v) => {
|
|
2714
3849
|
const n = v;
|
|
2715
|
-
return n > 10 ?
|
|
3850
|
+
return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
|
|
2716
3851
|
} },
|
|
2717
3852
|
{ key: "mem", label: "MEM%", align: "right", format: (v) => {
|
|
2718
3853
|
const n = v;
|
|
2719
|
-
return n > 10 ?
|
|
3854
|
+
return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
|
|
2720
3855
|
} },
|
|
2721
3856
|
{ key: "state", label: "State", format: (v) => {
|
|
2722
3857
|
const s = String(v);
|
|
2723
|
-
if (s === "R" || s === "Running") return
|
|
2724
|
-
if (s === "Z" || s === "Zombie") return
|
|
2725
|
-
if (s === "S" || s === "Sleeping") return
|
|
2726
|
-
return
|
|
3858
|
+
if (s === "R" || s === "Running") return pc6.green(s);
|
|
3859
|
+
if (s === "Z" || s === "Zombie") return pc6.red(s);
|
|
3860
|
+
if (s === "S" || s === "Sleeping") return pc6.cyan(s);
|
|
3861
|
+
return pc6.dim(s);
|
|
2727
3862
|
} }
|
|
2728
3863
|
];
|
|
2729
3864
|
const rows2 = processes.map((p) => ({ pid: p.pid, name: p.name, cpu: p.cpuPercent, mem: p.memPercent, state: formatProcessState(p.state) }));
|
|
2730
3865
|
console.error(`
|
|
2731
|
-
${symbols.fox} ${
|
|
3866
|
+
${symbols.fox} ${pc6.bold("System Processes")} ${pc6.dim(`(top ${processes.length} by ${sortBy})`)}
|
|
2732
3867
|
`);
|
|
2733
3868
|
console.error(renderTable(columns2, rows2));
|
|
2734
3869
|
console.error();
|
|
@@ -2741,49 +3876,49 @@ async function psCommand(args2) {
|
|
|
2741
3876
|
const tracked = readTracked();
|
|
2742
3877
|
if (tracked.length === 0) {
|
|
2743
3878
|
console.error(`
|
|
2744
|
-
${
|
|
2745
|
-
console.error(` ${
|
|
3879
|
+
${pc6.dim("No tracked processes.")}`);
|
|
3880
|
+
console.error(` ${pc6.dim("Start an app with:")} ${pc6.cyan("fennec start <command> --name <name>")}
|
|
2746
3881
|
`);
|
|
2747
3882
|
return;
|
|
2748
3883
|
}
|
|
2749
3884
|
const columns = [
|
|
2750
|
-
{ key: "name", label: "App", format: (v) =>
|
|
3885
|
+
{ key: "name", label: "App", format: (v) => pc6.bold(String(v)) },
|
|
2751
3886
|
{ key: "pid", label: "PID", align: "right" },
|
|
2752
3887
|
{ key: "status", label: "Status", format: (v) => {
|
|
2753
3888
|
const s = v;
|
|
2754
|
-
return s === "running" ?
|
|
3889
|
+
return s === "running" ? pc6.green("\u25CF running") : pc6.red("\u25CB stopped");
|
|
2755
3890
|
} },
|
|
2756
3891
|
{ key: "port", label: "Port", format: (v) => {
|
|
2757
3892
|
const p = v;
|
|
2758
|
-
return p ?
|
|
3893
|
+
return p ? pc6.yellow(`:${p}`) : pc6.dim("-");
|
|
2759
3894
|
} },
|
|
2760
3895
|
{ key: "command", label: "Command", format: (v) => {
|
|
2761
3896
|
const c = String(v);
|
|
2762
3897
|
return c.length > 50 ? c.slice(0, 50) + "\u2026" : c;
|
|
2763
3898
|
} },
|
|
2764
|
-
{ key: "uptime", label: "Uptime", format: (v) =>
|
|
3899
|
+
{ key: "uptime", label: "Uptime", format: (v) => pc6.dim(String(v)) }
|
|
2765
3900
|
];
|
|
2766
3901
|
const rows = tracked.map((t) => {
|
|
2767
|
-
const running =
|
|
3902
|
+
const running = isTrackedRunning(t);
|
|
2768
3903
|
const uptime = running ? formatUptime(Math.floor((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : "-";
|
|
2769
|
-
return { name: t.name, pid: running ? String(t.pid) :
|
|
3904
|
+
return { name: t.name, pid: running ? String(t.pid) : pc6.dim(String(t.pid)), status: running ? "running" : "stopped", port: t.port ?? null, command: t.command, uptime };
|
|
2770
3905
|
});
|
|
2771
|
-
const runningCount = tracked.filter((t) =>
|
|
3906
|
+
const runningCount = tracked.filter((t) => isTrackedRunning(t)).length;
|
|
2772
3907
|
console.error(`
|
|
2773
|
-
${symbols.fox} ${
|
|
3908
|
+
${symbols.fox} ${pc6.bold("Fennec Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running)`)}
|
|
2774
3909
|
`);
|
|
2775
3910
|
console.error(renderTable(columns, rows));
|
|
2776
|
-
console.error(` ${
|
|
2777
|
-
console.error(` ${
|
|
2778
|
-
console.error(` ${
|
|
2779
|
-
console.error(` ${
|
|
2780
|
-
console.error(` ${
|
|
3911
|
+
console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec start <command> --name <name> --port <port>")} ${pc6.dim("to add more apps.")}`);
|
|
3912
|
+
console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec log <name>")} ${pc6.dim("to view logs.")}`);
|
|
3913
|
+
console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec stop <name>")} ${pc6.dim("to pause an app.")}`);
|
|
3914
|
+
console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec spawn <name>")} ${pc6.dim("to resume a paused app.")}`);
|
|
3915
|
+
console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec kill <name>")} ${pc6.dim("to permanently remove an app.")}`);
|
|
2781
3916
|
console.error();
|
|
2782
3917
|
}
|
|
2783
3918
|
async function psJson() {
|
|
2784
3919
|
const tracked = readTracked();
|
|
2785
3920
|
const data = tracked.map((t) => {
|
|
2786
|
-
const running =
|
|
3921
|
+
const running = isTrackedRunning(t);
|
|
2787
3922
|
return {
|
|
2788
3923
|
name: t.name,
|
|
2789
3924
|
pid: t.pid,
|
|
@@ -2803,54 +3938,54 @@ async function statusCommand(_args) {
|
|
|
2803
3938
|
const topSystem = getSystemProcesses({ userOnly: true, sortBy: "cpu", limit: 5 });
|
|
2804
3939
|
const totalUserProcs = getSystemProcesses({ userOnly: true }).length;
|
|
2805
3940
|
console.error(`
|
|
2806
|
-
${symbols.fox} ${
|
|
3941
|
+
${symbols.fox} ${pc6.bold("Fennec Status")}
|
|
2807
3942
|
`);
|
|
2808
3943
|
if (tracked.length > 0) {
|
|
2809
|
-
const runningCount = tracked.filter((t) =>
|
|
2810
|
-
console.error(` ${
|
|
3944
|
+
const runningCount = tracked.filter((t) => isTrackedRunning(t)).length;
|
|
3945
|
+
console.error(` ${pc6.bold("Managed Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running)`)}
|
|
2811
3946
|
`);
|
|
2812
3947
|
for (const t of tracked) {
|
|
2813
|
-
const running =
|
|
2814
|
-
const statusIcon = running ?
|
|
2815
|
-
const portStr = t.port ? ` ${
|
|
2816
|
-
const uptime = running ?
|
|
2817
|
-
console.error(` ${statusIcon} ${
|
|
3948
|
+
const running = isTrackedRunning(t);
|
|
3949
|
+
const statusIcon = running ? pc6.green("\u25CF") : pc6.red("\u25CB");
|
|
3950
|
+
const portStr = t.port ? ` ${pc6.yellow(`:${t.port}`)}` : "";
|
|
3951
|
+
const uptime = running ? pc6.dim(formatUptime(Math.floor((Date.now() - new Date(t.startedAt).getTime()) / 1e3))) : pc6.red("stopped");
|
|
3952
|
+
console.error(` ${statusIcon} ${pc6.bold(t.name)}${portStr} ${pc6.dim(`(PID ${t.pid})`)} \u2014 ${uptime}`);
|
|
2818
3953
|
}
|
|
2819
3954
|
console.error();
|
|
2820
3955
|
} else {
|
|
2821
|
-
console.error(` ${
|
|
3956
|
+
console.error(` ${pc6.dim("No managed apps.")} ${pc6.cyan("fennec start <command> --name <name>")}
|
|
2822
3957
|
`);
|
|
2823
3958
|
}
|
|
2824
|
-
console.error(` ${
|
|
3959
|
+
console.error(` ${pc6.bold("System")} ${pc6.dim(`(${totalUserProcs} user processes)`)}`);
|
|
2825
3960
|
for (const p of topSystem) {
|
|
2826
|
-
const cpuStr = p.cpuPercent > 10 ?
|
|
2827
|
-
const memStr = p.memPercent > 10 ?
|
|
2828
|
-
console.error(` ${
|
|
3961
|
+
const cpuStr = p.cpuPercent > 10 ? pc6.red(`${p.cpuPercent}%`) : p.cpuPercent > 5 ? pc6.yellow(`${p.cpuPercent}%`) : pc6.dim(`${p.cpuPercent}%`);
|
|
3962
|
+
const memStr = p.memPercent > 10 ? pc6.red(`${p.memPercent}%`) : p.memPercent > 5 ? pc6.yellow(`${p.memPercent}%`) : pc6.dim(`${p.memPercent}%`);
|
|
3963
|
+
console.error(` ${pc6.dim(`PID ${p.pid}`)} ${pc6.bold(p.name)} \u2014 CPU: ${cpuStr} MEM: ${memStr}`);
|
|
2829
3964
|
}
|
|
2830
3965
|
if (watchFlag) await watchSystemProcesses("cpu", 15);
|
|
2831
3966
|
console.error();
|
|
2832
3967
|
}
|
|
2833
3968
|
async function watchSystemProcesses(sortBy, limit) {
|
|
2834
3969
|
console.error(`
|
|
2835
|
-
${
|
|
3970
|
+
${pc6.bold("Watching system processes")} ${pc6.dim("(Ctrl+C to stop, refreshes every 3s)")}
|
|
2836
3971
|
`);
|
|
2837
3972
|
const render = () => {
|
|
2838
3973
|
const processes = getSystemProcesses({ userOnly: true, sortBy, limit });
|
|
2839
3974
|
const columns = [
|
|
2840
|
-
{ key: "pid", label: "PID", align: "right", format: (v) =>
|
|
2841
|
-
{ key: "name", label: "Name", format: (v) =>
|
|
3975
|
+
{ key: "pid", label: "PID", align: "right", format: (v) => pc6.dim(String(v).padStart(6)) },
|
|
3976
|
+
{ key: "name", label: "Name", format: (v) => pc6.bold(String(v)) },
|
|
2842
3977
|
{ key: "cpu", label: "CPU%", align: "right", format: (v) => {
|
|
2843
3978
|
const n = v;
|
|
2844
|
-
return n > 10 ?
|
|
3979
|
+
return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
|
|
2845
3980
|
} },
|
|
2846
3981
|
{ key: "mem", label: "MEM%", align: "right", format: (v) => {
|
|
2847
3982
|
const n = v;
|
|
2848
|
-
return n > 10 ?
|
|
3983
|
+
return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
|
|
2849
3984
|
} },
|
|
2850
3985
|
{ key: "state", label: "S", align: "center" }
|
|
2851
3986
|
];
|
|
2852
3987
|
const rows = processes.map((p) => ({ pid: p.pid, name: p.name, cpu: p.cpuPercent, mem: p.memPercent, state: p.state }));
|
|
2853
|
-
return ` ${timestamp()} ${
|
|
3988
|
+
return ` ${timestamp()} ${pc6.dim(`${processes.length} processes`)}
|
|
2854
3989
|
${renderTable(columns, rows, { compact: true })}`;
|
|
2855
3990
|
};
|
|
2856
3991
|
console.error(render());
|
|
@@ -2858,19 +3993,35 @@ ${renderTable(columns, rows, { compact: true })}`;
|
|
|
2858
3993
|
process.stdout.write("\x1B[J");
|
|
2859
3994
|
console.error(render());
|
|
2860
3995
|
}, 3e3);
|
|
2861
|
-
await new Promise((
|
|
3996
|
+
await new Promise((resolve13) => {
|
|
2862
3997
|
process.once("SIGINT", () => {
|
|
2863
3998
|
clearInterval(interval);
|
|
2864
3999
|
process.stdout.write("\x1B[J");
|
|
2865
|
-
|
|
4000
|
+
resolve13();
|
|
2866
4001
|
});
|
|
2867
4002
|
});
|
|
2868
4003
|
}
|
|
2869
4004
|
|
|
2870
4005
|
// src/commands/start.ts
|
|
4006
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4007
|
+
async function waitForReady(pid, port, timeoutMs = 8e3) {
|
|
4008
|
+
const start = Date.now();
|
|
4009
|
+
const graceMs = 700;
|
|
4010
|
+
while (Date.now() - start < timeoutMs) {
|
|
4011
|
+
if (!isProcessRunning(pid)) return { running: false, portReady: false };
|
|
4012
|
+
if (port !== void 0) {
|
|
4013
|
+
if (await checkPort(port)) return { running: true, portReady: true };
|
|
4014
|
+
await sleep(250);
|
|
4015
|
+
continue;
|
|
4016
|
+
}
|
|
4017
|
+
if (Date.now() - start >= graceMs) return { running: true, portReady: false };
|
|
4018
|
+
await sleep(150);
|
|
4019
|
+
}
|
|
4020
|
+
return { running: isProcessRunning(pid), portReady: port === void 0 ? false : false };
|
|
4021
|
+
}
|
|
2871
4022
|
async function startServer(args2) {
|
|
2872
4023
|
printBanner();
|
|
2873
|
-
console.error(` ${
|
|
4024
|
+
console.error(` ${pc7.dim("Starting Fennec MCP server...")}
|
|
2874
4025
|
`);
|
|
2875
4026
|
const configIndex = args2.indexOf("--config");
|
|
2876
4027
|
const configPath = configIndex !== -1 ? args2[configIndex + 1] : void 0;
|
|
@@ -2885,22 +4036,22 @@ async function startServer(args2) {
|
|
|
2885
4036
|
if (useSse) {
|
|
2886
4037
|
const port = server.getConfig().transport.port;
|
|
2887
4038
|
console.error(`
|
|
2888
|
-
${
|
|
2889
|
-
console.error(` ${renderKV("Transport",
|
|
2890
|
-
console.error(` ${renderKV("URL",
|
|
2891
|
-
console.error(` ${renderKV("MCP Config",
|
|
2892
|
-
console.error(` ${renderKV("OpenCode",
|
|
4039
|
+
${pc7.green("\u2713")} ${pc7.bold("Fennec server is running")}`);
|
|
4040
|
+
console.error(` ${renderKV("Transport", pc7.cyan("SSE (HTTP)"))}`);
|
|
4041
|
+
console.error(` ${renderKV("URL", pc7.cyan(`http://localhost:${port}/sse`))}`);
|
|
4042
|
+
console.error(` ${renderKV("MCP Config", pc7.cyan(`{ "type": "remote", "url": "http://localhost:${port}/sse" }`))}`);
|
|
4043
|
+
console.error(` ${renderKV("OpenCode", pc7.cyan(`type: remote, url: http://localhost:${port}/sse`))}`);
|
|
2893
4044
|
console.error(`
|
|
2894
|
-
${
|
|
4045
|
+
${pc7.dim("Press Ctrl+C to stop")}
|
|
2895
4046
|
`);
|
|
2896
4047
|
} else {
|
|
2897
4048
|
console.error(`
|
|
2898
|
-
${
|
|
4049
|
+
${pc7.green("\u2713")} ${pc7.bold("Fennec server is running")}`);
|
|
2899
4050
|
console.error(` ${renderKV("Transport", "stdio")}`);
|
|
2900
4051
|
console.error(` ${renderKV("AI Agent", "Connect via MCP protocol")}`);
|
|
2901
|
-
console.error(` ${renderKV("Tip",
|
|
4052
|
+
console.error(` ${renderKV("Tip", pc7.dim("For SSE mode: fennec start --sse"))}`);
|
|
2902
4053
|
console.error(`
|
|
2903
|
-
${
|
|
4054
|
+
${pc7.dim("Press Ctrl+C to stop")}
|
|
2904
4055
|
`);
|
|
2905
4056
|
}
|
|
2906
4057
|
} catch (error) {
|
|
@@ -2910,7 +4061,7 @@ async function startServer(args2) {
|
|
|
2910
4061
|
}
|
|
2911
4062
|
async function startCommand(args2) {
|
|
2912
4063
|
printBanner();
|
|
2913
|
-
console.error(` ${
|
|
4064
|
+
console.error(` ${pc7.dim("Starting app...")}
|
|
2914
4065
|
`);
|
|
2915
4066
|
await runCommand(args2);
|
|
2916
4067
|
}
|
|
@@ -2920,8 +4071,9 @@ async function runCommand(args2) {
|
|
|
2920
4071
|
const portIndex = args2.indexOf("--port");
|
|
2921
4072
|
const port = portIndex !== -1 ? parseInt(args2[portIndex + 1], 10) : void 0;
|
|
2922
4073
|
const cwdIndex = args2.indexOf("--cwd");
|
|
2923
|
-
const cwd = cwdIndex !== -1 ? args2[cwdIndex + 1] :
|
|
4074
|
+
const cwd = cwdIndex !== -1 ? resolve4(args2[cwdIndex + 1]) : process.cwd();
|
|
2924
4075
|
const restartFlag = args2.includes("--restart");
|
|
4076
|
+
const jsonlFlag = args2.includes("--jsonl");
|
|
2925
4077
|
const stopFlags = [nameIndex, portIndex, cwdIndex].filter((i) => i !== -1);
|
|
2926
4078
|
const cmdEnd = Math.min(...stopFlags, Infinity);
|
|
2927
4079
|
const cmdParts = args2.slice(0, cmdEnd);
|
|
@@ -2931,87 +4083,82 @@ async function runCommand(args2) {
|
|
|
2931
4083
|
process.exit(1);
|
|
2932
4084
|
}
|
|
2933
4085
|
const appName = name ?? cmdParts[0] ?? "app";
|
|
4086
|
+
if (port !== void 0) {
|
|
4087
|
+
const adopted = adoptExternalOnPort(port, appName);
|
|
4088
|
+
if (adopted) {
|
|
4089
|
+
console.error(`
|
|
4090
|
+
${pc7.green("\u2713")} ${pc7.bold("Adopted")} ${pc7.bold(adopted.name)} ${pc7.dim(`(PID ${adopted.pid}) \u2014 already listening on :${port}`)}`);
|
|
4091
|
+
console.error(` ${renderKV("Logs", pc7.cyan(`fennec log ${adopted.name}`))}`);
|
|
4092
|
+
console.error();
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
2934
4096
|
const tracked = readTracked();
|
|
2935
4097
|
const existing = tracked.find((t) => t.name === appName);
|
|
2936
|
-
if (existing &&
|
|
4098
|
+
if (existing && isTrackedRunning(existing)) {
|
|
2937
4099
|
console.error();
|
|
2938
|
-
console.error(` ${
|
|
2939
|
-
console.error(` ${renderKV("Logs",
|
|
2940
|
-
console.error(` ${renderKV("Stop",
|
|
4100
|
+
console.error(` ${pc7.yellow("\u26A0")} ${pc7.bold(appName)} ${pc7.dim(`is already running (PID: ${existing.pid})`)}`);
|
|
4101
|
+
console.error(` ${renderKV("Logs", pc7.cyan(`fennec log ${appName}`))}`);
|
|
4102
|
+
console.error(` ${renderKV("Stop", pc7.cyan(`fennec kill ${appName}`))}`);
|
|
2941
4103
|
console.error();
|
|
2942
4104
|
process.exit(0);
|
|
2943
4105
|
}
|
|
2944
|
-
const logDir =
|
|
2945
|
-
|
|
2946
|
-
const logFilePath =
|
|
4106
|
+
const logDir = dirname4(logFilePathFor(appName));
|
|
4107
|
+
mkdirSync4(logDir, { recursive: true });
|
|
4108
|
+
const logFilePath = logFilePathFor(appName);
|
|
2947
4109
|
console.error(`
|
|
2948
|
-
${symbols.fox} ${
|
|
4110
|
+
${symbols.fox} ${pc7.bold("Starting")} ${renderAppName(appName)} ${pc7.dim("(daemon)")}
|
|
2949
4111
|
`);
|
|
2950
4112
|
console.error(` ${renderKV("Command", cmd)}`);
|
|
2951
4113
|
if (port) console.error(` ${renderKV("Port", String(port))}`);
|
|
2952
4114
|
if (cwd) console.error(` ${renderKV("Directory", cwd)}`);
|
|
2953
|
-
if (restartFlag) console.error(` ${renderKV("Auto-restart",
|
|
4115
|
+
if (restartFlag) console.error(` ${renderKV("Auto-restart", pc7.green("enabled"))}`);
|
|
2954
4116
|
console.error(` ${divider()}`);
|
|
2955
4117
|
try {
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
});
|
|
4118
|
+
const startEnv = {};
|
|
4119
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
4120
|
+
if (v !== void 0) startEnv[k] = v;
|
|
4121
|
+
}
|
|
4122
|
+
const currentChild = spawnDaemon({ cmdParts, name: appName, cwd, logFilePath, env: buildSpawnEnv(startEnv), logMode: jsonlFlag ? "jsonl" : "text" });
|
|
2962
4123
|
const pid = currentChild.pid ?? 0;
|
|
2963
|
-
rotateLogFile(logFilePath);
|
|
2964
|
-
const logStream = createWriteStream(logFilePath, { flags: "a" });
|
|
2965
|
-
if (currentChild.stdout) currentChild.stdout.pipe(logStream);
|
|
2966
|
-
if (currentChild.stderr) currentChild.stderr.pipe(logStream);
|
|
2967
|
-
currentChild.unref();
|
|
2968
4124
|
addTracked({
|
|
2969
4125
|
name: appName,
|
|
2970
4126
|
pid,
|
|
2971
4127
|
command: cmd,
|
|
4128
|
+
args: cmdParts,
|
|
2972
4129
|
port,
|
|
2973
4130
|
cwd,
|
|
2974
|
-
|
|
4131
|
+
env: startEnv,
|
|
4132
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4133
|
+
autoRestart: restartFlag,
|
|
4134
|
+
logMode: jsonlFlag ? "jsonl" : "text"
|
|
2975
4135
|
});
|
|
2976
|
-
console.error(` ${
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
addTracked({ name: appName, pid: newPid, command: cmd, port, cwd, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2995
|
-
console.error(` ${pc5.green("\u2713")} ${pc5.bold(appName)} ${pc5.dim(`restarted (PID: ${newPid})`)}`);
|
|
2996
|
-
currentChild = restarted;
|
|
2997
|
-
setupRestartWatch2(restarted);
|
|
2998
|
-
}
|
|
2999
|
-
});
|
|
3000
|
-
};
|
|
3001
|
-
var setupRestartWatch = setupRestartWatch2;
|
|
3002
|
-
console.error(` ${pc5.dim("Auto-restart enabled \u2014 watching for crashes...")}
|
|
3003
|
-
`);
|
|
3004
|
-
setupRestartWatch2(currentChild);
|
|
3005
|
-
await new Promise((resolve12) => {
|
|
3006
|
-
process.once("SIGINT", () => {
|
|
3007
|
-
currentChild.kill("SIGTERM");
|
|
3008
|
-
resolve12();
|
|
3009
|
-
});
|
|
3010
|
-
});
|
|
4136
|
+
console.error(` ${pc7.green("\u2713")} ${pc7.bold(appName)} ${pc7.dim(`started (PID: ${pid})`)}`);
|
|
4137
|
+
const spinner = createSpinner(port ? `Waiting for ${appName} on port ${port}...` : `Confirming ${appName} is running...`);
|
|
4138
|
+
const ready = await waitForReady(pid, port);
|
|
4139
|
+
spinner.stop();
|
|
4140
|
+
process.stdout.write("\r\x1B[K");
|
|
4141
|
+
if (!ready.running) {
|
|
4142
|
+
removeTracked(appName);
|
|
4143
|
+
console.error(` ${pc7.red("\u2717")} ${pc7.bold(appName)} ${pc7.dim("exited immediately after start")}`);
|
|
4144
|
+
console.error(` ${renderKV("Logs", pc7.cyan(`fennec log ${appName}`))}`);
|
|
4145
|
+
console.error();
|
|
4146
|
+
process.exit(1);
|
|
4147
|
+
}
|
|
4148
|
+
if (port) {
|
|
4149
|
+
if (ready.portReady) {
|
|
4150
|
+
console.error(` ${pc7.green("\u2713")} ${pc7.bold(appName)} ${pc7.dim(`is listening on port ${port}`)}`);
|
|
4151
|
+
} else {
|
|
4152
|
+
console.error(` ${pc7.yellow("\u26A0")} ${pc7.bold(appName)} ${pc7.dim(`is running but port ${port} is not accepting connections yet`)}`);
|
|
4153
|
+
}
|
|
3011
4154
|
}
|
|
3012
|
-
if (
|
|
3013
|
-
|
|
4155
|
+
if (restartFlag) {
|
|
4156
|
+
const supPid = ensureSupervisorRunning();
|
|
4157
|
+
ensurePersistEnabled();
|
|
4158
|
+
console.error(` ${pc7.green("\u2713")} ${pc7.bold("Auto-restart")} ${pc7.dim(`managed by supervisor (PID ${supPid}) \u2014 survives terminal close`)}`);
|
|
4159
|
+
console.error(` ${renderKV("Supervisor", pc7.cyan("fennec supervisor status"))}`);
|
|
3014
4160
|
}
|
|
4161
|
+
await psCommand([]);
|
|
3015
4162
|
} catch (error) {
|
|
3016
4163
|
console.error(renderError(`Failed to start ${appName}`, String(error)));
|
|
3017
4164
|
process.exit(1);
|
|
@@ -3020,7 +4167,7 @@ async function runCommand(args2) {
|
|
|
3020
4167
|
async function resurrectTracked() {
|
|
3021
4168
|
const tracked = readTracked();
|
|
3022
4169
|
if (tracked.length === 0) return;
|
|
3023
|
-
const dead = tracked.filter((t) => !
|
|
4170
|
+
const dead = tracked.filter((t) => !isTrackedRunning(t));
|
|
3024
4171
|
if (dead.length === 0) return;
|
|
3025
4172
|
const spinner = createSpinner(`Resurrecting ${dead.length} stopped process(es)...`);
|
|
3026
4173
|
let resurrected = 0;
|
|
@@ -3031,28 +4178,21 @@ async function resurrectTracked() {
|
|
|
3031
4178
|
continue;
|
|
3032
4179
|
}
|
|
3033
4180
|
try {
|
|
3034
|
-
const cmdParts = proc
|
|
3035
|
-
const
|
|
3036
|
-
|
|
3037
|
-
const logFilePath = resolve3(logDir, `${proc.name}.log`);
|
|
3038
|
-
const child = spawn(cmdParts[0], cmdParts.slice(1), {
|
|
3039
|
-
cwd: proc.cwd,
|
|
3040
|
-
env: { ...process.env },
|
|
3041
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3042
|
-
detached: true
|
|
3043
|
-
});
|
|
3044
|
-
rotateLogFile(logFilePath);
|
|
3045
|
-
const logStream = createWriteStream(logFilePath, { flags: "a" });
|
|
3046
|
-
if (child.stdout) child.stdout.pipe(logStream);
|
|
3047
|
-
if (child.stderr) child.stderr.pipe(logStream);
|
|
3048
|
-
child.unref();
|
|
4181
|
+
const cmdParts = resolveArgs(proc);
|
|
4182
|
+
const logFilePath = logFilePathFor(proc.name);
|
|
4183
|
+
const child = spawnDaemon({ cmdParts, name: proc.name, cwd: proc.cwd, logFilePath, env: buildSpawnEnv(proc.env), logMode: proc.logMode });
|
|
3049
4184
|
addTracked({
|
|
3050
4185
|
name: proc.name,
|
|
3051
4186
|
pid: child.pid ?? 0,
|
|
3052
4187
|
command: proc.command,
|
|
4188
|
+
args: cmdParts,
|
|
3053
4189
|
port: proc.port,
|
|
3054
4190
|
cwd: proc.cwd,
|
|
3055
|
-
|
|
4191
|
+
env: proc.env,
|
|
4192
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4193
|
+
autoRestart: proc.autoRestart,
|
|
4194
|
+
restartCause: proc.restartCause,
|
|
4195
|
+
logMode: proc.logMode
|
|
3056
4196
|
});
|
|
3057
4197
|
resurrected++;
|
|
3058
4198
|
} catch {
|
|
@@ -3062,22 +4202,23 @@ async function resurrectTracked() {
|
|
|
3062
4202
|
spinner.stop();
|
|
3063
4203
|
process.stdout.write("\r\x1B[K");
|
|
3064
4204
|
if (resurrected > 0) {
|
|
3065
|
-
console.error(` ${
|
|
4205
|
+
console.error(` ${pc7.green("\u25CF")} ${pc7.bold(`Resurrected ${resurrected} process(es)`)}`);
|
|
3066
4206
|
}
|
|
3067
4207
|
if (failed > 0) {
|
|
3068
|
-
console.error(` ${
|
|
4208
|
+
console.error(` ${pc7.red("\u25CF")} ${pc7.bold(`${failed} process(es) failed to resurrect`)}`);
|
|
3069
4209
|
}
|
|
3070
4210
|
}
|
|
3071
4211
|
|
|
3072
4212
|
// src/commands/kill.ts
|
|
3073
|
-
import
|
|
4213
|
+
import pc8 from "picocolors";
|
|
3074
4214
|
async function killCommand(args2) {
|
|
3075
4215
|
const rawTarget = args2[0];
|
|
3076
4216
|
const signalIndex = args2.indexOf("--signal");
|
|
3077
4217
|
const signalRaw = signalIndex !== -1 ? args2[signalIndex + 1] : "SIGTERM";
|
|
3078
4218
|
const signal = signalRaw ?? "SIGTERM";
|
|
4219
|
+
const force = args2.includes("-y") || args2.includes("--yes");
|
|
3079
4220
|
if (rawTarget === "all" || args2.includes("--all") || args2.includes("-a")) {
|
|
3080
|
-
await killAll(signal);
|
|
4221
|
+
await killAll(signal, force);
|
|
3081
4222
|
return;
|
|
3082
4223
|
}
|
|
3083
4224
|
if (!rawTarget) {
|
|
@@ -3097,21 +4238,29 @@ async function killCommand(args2) {
|
|
|
3097
4238
|
} else {
|
|
3098
4239
|
const tracked = readTracked();
|
|
3099
4240
|
const trackedMatch = tracked.find((t) => t.name === rawTarget);
|
|
3100
|
-
if (trackedMatch
|
|
4241
|
+
if (trackedMatch) {
|
|
4242
|
+
if (!isTrackedRunning(trackedMatch)) {
|
|
4243
|
+
console.error(`
|
|
4244
|
+
${pc8.yellow("\u26A0")} ${pc8.bold(rawTarget)} ${pc8.dim("is already stopped (no running process to kill)")}`);
|
|
4245
|
+
console.error(` ${pc8.dim("Use")} ${pc8.cyan("fennec spawn")} ${pc8.dim("to resume")}`);
|
|
4246
|
+
process.exit(0);
|
|
4247
|
+
}
|
|
3101
4248
|
targetPid = trackedMatch.pid;
|
|
3102
4249
|
displayName = `${trackedMatch.name} (PID ${targetPid})`;
|
|
3103
4250
|
} else {
|
|
3104
|
-
const
|
|
4251
|
+
const currentPid = process.pid;
|
|
4252
|
+
let matches = getSystemProcesses({ name: rawTarget, userOnly: true, sortBy: "cpu" });
|
|
4253
|
+
matches = matches.filter((m) => m.pid !== currentPid);
|
|
3105
4254
|
if (matches.length === 0) {
|
|
3106
4255
|
console.error(renderError("Process not found", `No running process matching "${rawTarget}"`));
|
|
3107
4256
|
process.exit(1);
|
|
3108
4257
|
}
|
|
3109
4258
|
if (matches.length > 1) {
|
|
3110
4259
|
console.error(`
|
|
3111
|
-
${
|
|
4260
|
+
${pc8.bold(`Multiple processes match "${rawTarget}":`)}`);
|
|
3112
4261
|
const selected = await selectPrompt("Select which to kill:", matches.map((p, i) => ({ value: String(i), label: `${p.name} (PID ${p.pid})`, description: `${p.command.slice(0, 80)} \u2014 CPU: ${p.cpuPercent}% MEM: ${p.memPercent}%` })));
|
|
3113
4262
|
if (selected === null) {
|
|
3114
|
-
console.error(` ${
|
|
4263
|
+
console.error(` ${pc8.dim("Cancelled")}`);
|
|
3115
4264
|
return;
|
|
3116
4265
|
}
|
|
3117
4266
|
const idx = parseInt(selected, 10);
|
|
@@ -3123,9 +4272,9 @@ async function killCommand(args2) {
|
|
|
3123
4272
|
}
|
|
3124
4273
|
}
|
|
3125
4274
|
}
|
|
3126
|
-
const confirmed = await confirmPrompt(`Kill ${
|
|
4275
|
+
const confirmed = force || await confirmPrompt(`Kill ${pc8.bold(displayName)} with ${pc8.yellow(signal)}?`, false);
|
|
3127
4276
|
if (!confirmed) {
|
|
3128
|
-
console.error(` ${
|
|
4277
|
+
console.error(` ${pc8.dim("Cancelled")}`);
|
|
3129
4278
|
return;
|
|
3130
4279
|
}
|
|
3131
4280
|
const spinner = createSpinner(`Sending ${signal} to ${displayName}...`);
|
|
@@ -3135,16 +4284,16 @@ async function killCommand(args2) {
|
|
|
3135
4284
|
const stillRunning = isProcessRunning(targetPid);
|
|
3136
4285
|
if (stillRunning && signal !== "SIGKILL") {
|
|
3137
4286
|
spinner.warn(`${displayName} did not respond to ${signal}`);
|
|
3138
|
-
const forceKill = await confirmPrompt(`Send ${
|
|
4287
|
+
const forceKill = force || await confirmPrompt(`Send ${pc8.red("SIGKILL")} to force stop?`, true);
|
|
3139
4288
|
if (forceKill) {
|
|
3140
4289
|
const forceSpinner = createSpinner(`Sending SIGKILL to ${displayName}...`);
|
|
3141
4290
|
killProcess(targetPid, "SIGKILL");
|
|
3142
4291
|
await new Promise((r) => setTimeout(r, 300));
|
|
3143
4292
|
isProcessRunning(targetPid) ? forceSpinner.fail(`${displayName} could not be stopped (permission denied?)`) : forceSpinner.succeed(`${displayName} force stopped`);
|
|
3144
4293
|
} else {
|
|
3145
|
-
console.error(` ${
|
|
4294
|
+
console.error(` ${pc8.dim("Retrying with SIGTERM...")}`);
|
|
3146
4295
|
killProcess(targetPid, "SIGTERM");
|
|
3147
|
-
console.error(` ${
|
|
4296
|
+
console.error(` ${pc8.yellow("\u26A0")} ${displayName} ${pc8.dim("may still be running")}`);
|
|
3148
4297
|
}
|
|
3149
4298
|
} else {
|
|
3150
4299
|
removeTrackedByPid(targetPid);
|
|
@@ -3155,44 +4304,43 @@ async function killCommand(args2) {
|
|
|
3155
4304
|
console.error(renderError("Permission denied", "Try running with sudo or use a different signal."));
|
|
3156
4305
|
}
|
|
3157
4306
|
}
|
|
3158
|
-
async function killAll(signal) {
|
|
3159
|
-
const
|
|
3160
|
-
|
|
3161
|
-
|
|
4307
|
+
async function killAll(signal, force) {
|
|
4308
|
+
const tracked = readTracked();
|
|
4309
|
+
const running = tracked.filter((t) => isTrackedRunning(t));
|
|
4310
|
+
if (running.length === 0) {
|
|
4311
|
+
console.error(` ${pc8.dim("No running tracked apps to kill.")}`);
|
|
3162
4312
|
return;
|
|
3163
4313
|
}
|
|
3164
4314
|
console.error(`
|
|
3165
|
-
${
|
|
3166
|
-
console.error(` ${
|
|
3167
|
-
console.error(` ${pc6.yellow("\u26A0 System processes will not be affected.")}
|
|
4315
|
+
${pc8.bold(`Kill ${running.length} tracked app(s)?`)}`);
|
|
4316
|
+
console.error(` ${pc8.dim("This stops every Fennec-tracked running process only \u2014 other processes are untouched.")}
|
|
3168
4317
|
`);
|
|
3169
|
-
const confirmed = await confirmPrompt(`${
|
|
4318
|
+
const confirmed = force || await confirmPrompt(`${pc8.red("Are you sure?")} ${pc8.dim("This cannot be undone")}`, false);
|
|
3170
4319
|
if (!confirmed) {
|
|
3171
|
-
console.error(` ${
|
|
4320
|
+
console.error(` ${pc8.dim("Cancelled.")}`);
|
|
3172
4321
|
return;
|
|
3173
4322
|
}
|
|
3174
|
-
const spinner = createSpinner(`Killing ${
|
|
4323
|
+
const spinner = createSpinner(`Killing ${running.length} tracked app(s)...`);
|
|
3175
4324
|
let killed = 0, failed = 0;
|
|
3176
|
-
for (const
|
|
3177
|
-
if (killProcess(
|
|
4325
|
+
for (const t of running) {
|
|
4326
|
+
if (killProcess(t.pid, signal)) {
|
|
3178
4327
|
killed++;
|
|
3179
|
-
removeTrackedByPid(
|
|
4328
|
+
removeTrackedByPid(t.pid);
|
|
3180
4329
|
} else {
|
|
3181
4330
|
failed++;
|
|
3182
4331
|
}
|
|
3183
|
-
if (killed % 10 === 0) await new Promise((r) => setTimeout(r, 50));
|
|
3184
4332
|
}
|
|
3185
|
-
await new Promise((r) => setTimeout(r,
|
|
3186
|
-
killed > 0 ? spinner.succeed(`${killed}
|
|
3187
|
-
if (failed > 0) console.error(` ${
|
|
4333
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
4334
|
+
killed > 0 ? spinner.succeed(`${killed} tracked app(s) killed`) : spinner.fail("Failed to kill apps");
|
|
4335
|
+
if (failed > 0) console.error(` ${pc8.yellow(`${failed} app(s) could not be killed`)}`);
|
|
3188
4336
|
}
|
|
3189
4337
|
|
|
3190
4338
|
// src/commands/stop.ts
|
|
3191
|
-
import
|
|
4339
|
+
import pc9 from "picocolors";
|
|
3192
4340
|
async function stopCommand(args2) {
|
|
3193
4341
|
const stopAll = args2.includes("--all") || args2.includes("-a");
|
|
3194
4342
|
if (stopAll) {
|
|
3195
|
-
await stopAllTracked();
|
|
4343
|
+
await stopAllTracked(args2);
|
|
3196
4344
|
return;
|
|
3197
4345
|
}
|
|
3198
4346
|
const rawTarget = args2[0];
|
|
@@ -3203,42 +4351,45 @@ async function stopCommand(args2) {
|
|
|
3203
4351
|
const tracked = readTracked();
|
|
3204
4352
|
const trackedMatch = tracked.find((t) => t.name === rawTarget);
|
|
3205
4353
|
if (!trackedMatch) {
|
|
3206
|
-
console.error(renderError("Not found", `No tracked process named "${rawTarget}". Use ${
|
|
4354
|
+
console.error(renderError("Not found", `No tracked process named "${rawTarget}". Use ${pc9.cyan("fennec kill <pid>")} to kill a system process.`));
|
|
3207
4355
|
process.exit(1);
|
|
3208
4356
|
}
|
|
3209
|
-
if (!
|
|
4357
|
+
if (!isTrackedRunning(trackedMatch)) {
|
|
3210
4358
|
console.error(`
|
|
3211
|
-
${
|
|
4359
|
+
${pc9.yellow("\u26A0")} ${pc9.bold(rawTarget)} ${pc9.dim("is already stopped")}`);
|
|
3212
4360
|
console.error();
|
|
3213
4361
|
process.exit(0);
|
|
3214
4362
|
}
|
|
3215
4363
|
const displayName = `${trackedMatch.name} (PID ${trackedMatch.pid})`;
|
|
3216
|
-
const
|
|
4364
|
+
const force = args2.includes("-y") || args2.includes("--yes");
|
|
4365
|
+
const confirmed = force || await confirmPrompt(`Stop ${pc9.bold(displayName)}? ${pc9.dim("It can be re-spawned later via fennec spawn")}`, false);
|
|
3217
4366
|
if (!confirmed) {
|
|
3218
|
-
console.error(` ${
|
|
4367
|
+
console.error(` ${pc9.dim("Cancelled")}`);
|
|
3219
4368
|
return;
|
|
3220
4369
|
}
|
|
4370
|
+
setAutoRestart(trackedMatch.name, false);
|
|
3221
4371
|
await stopSingleProcess(trackedMatch.pid, trackedMatch.name);
|
|
3222
4372
|
}
|
|
3223
|
-
async function stopAllTracked() {
|
|
4373
|
+
async function stopAllTracked(args2) {
|
|
3224
4374
|
const tracked = readTracked();
|
|
3225
|
-
const running = tracked.filter((t) =>
|
|
4375
|
+
const running = tracked.filter((t) => isTrackedRunning(t));
|
|
3226
4376
|
if (running.length === 0) {
|
|
3227
4377
|
console.error(`
|
|
3228
|
-
${
|
|
4378
|
+
${pc9.dim("No running tracked processes to stop.")}
|
|
3229
4379
|
`);
|
|
3230
4380
|
return;
|
|
3231
4381
|
}
|
|
3232
4382
|
console.error(`
|
|
3233
|
-
${
|
|
4383
|
+
${pc9.yellow("\u26A0")} ${pc9.bold(`Stop all ${running.length} running process(es)?`)} ${pc9.dim("(They can be re-spawned later)")}
|
|
3234
4384
|
`);
|
|
3235
4385
|
for (const t of running) {
|
|
3236
|
-
console.error(` ${
|
|
4386
|
+
console.error(` ${pc9.green("\u25CF")} ${pc9.bold(t.name)} ${pc9.dim(`(PID ${t.pid})`)}`);
|
|
3237
4387
|
}
|
|
3238
4388
|
console.error();
|
|
3239
|
-
const
|
|
4389
|
+
const forceAll = args2.includes("-y") || args2.includes("--yes");
|
|
4390
|
+
const confirmed = forceAll || await confirmPrompt("Stop all?", false);
|
|
3240
4391
|
if (!confirmed) {
|
|
3241
|
-
console.error(` ${
|
|
4392
|
+
console.error(` ${pc9.dim("Cancelled")}
|
|
3242
4393
|
`);
|
|
3243
4394
|
return;
|
|
3244
4395
|
}
|
|
@@ -3246,6 +4397,7 @@ async function stopAllTracked() {
|
|
|
3246
4397
|
let stopped = 0;
|
|
3247
4398
|
let failed = 0;
|
|
3248
4399
|
for (const t of running) {
|
|
4400
|
+
setAutoRestart(t.name, false);
|
|
3249
4401
|
if (killProcess(t.pid, "SIGTERM")) {
|
|
3250
4402
|
stopped++;
|
|
3251
4403
|
} else {
|
|
@@ -3256,10 +4408,10 @@ async function stopAllTracked() {
|
|
|
3256
4408
|
spinner.stop();
|
|
3257
4409
|
process.stdout.write("\r\x1B[K");
|
|
3258
4410
|
if (stopped > 0) {
|
|
3259
|
-
console.error(` ${
|
|
4411
|
+
console.error(` ${pc9.green("\u2713")} ${pc9.bold(`Stopped ${stopped} process(es)`)} ${pc9.dim("(kept in tracked.json)")}`);
|
|
3260
4412
|
}
|
|
3261
4413
|
if (failed > 0) {
|
|
3262
|
-
console.error(` ${
|
|
4414
|
+
console.error(` ${pc9.red("\u2717")} ${pc9.bold(`${failed} process(es) failed to stop`)}`);
|
|
3263
4415
|
}
|
|
3264
4416
|
console.error();
|
|
3265
4417
|
await psCommand([]);
|
|
@@ -3273,14 +4425,14 @@ async function stopSingleProcess(pid, name) {
|
|
|
3273
4425
|
const stillRunning = isProcessRunning(pid);
|
|
3274
4426
|
if (stillRunning) {
|
|
3275
4427
|
spinner.warn(`${displayName} did not respond to SIGTERM`);
|
|
3276
|
-
const forceKill = await confirmPrompt(`Send ${
|
|
4428
|
+
const forceKill = await confirmPrompt(`Send ${pc9.red("SIGKILL")} to force stop?`, true);
|
|
3277
4429
|
if (forceKill) {
|
|
3278
4430
|
const forceSpinner = createSpinner(`Sending SIGKILL to ${displayName}...`);
|
|
3279
4431
|
killProcess(pid, "SIGKILL");
|
|
3280
4432
|
await new Promise((r) => setTimeout(r, 300));
|
|
3281
4433
|
isProcessRunning(pid) ? forceSpinner.fail(`${displayName} could not be stopped (permission denied?)`) : forceSpinner.succeed(`${displayName} force stopped`);
|
|
3282
4434
|
} else {
|
|
3283
|
-
console.error(` ${
|
|
4435
|
+
console.error(` ${pc9.yellow("\u26A0")} ${displayName} ${pc9.dim("may still be running")}`);
|
|
3284
4436
|
}
|
|
3285
4437
|
} else {
|
|
3286
4438
|
spinner.succeed(`${displayName} stopped`);
|
|
@@ -3294,11 +4446,10 @@ async function stopSingleProcess(pid, name) {
|
|
|
3294
4446
|
}
|
|
3295
4447
|
|
|
3296
4448
|
// src/commands/spawn.ts
|
|
3297
|
-
import
|
|
3298
|
-
import {
|
|
3299
|
-
import { resolve as
|
|
4449
|
+
import pc10 from "picocolors";
|
|
4450
|
+
import { mkdirSync as mkdirSync5 } from "fs";
|
|
4451
|
+
import { resolve as resolve5 } from "path";
|
|
3300
4452
|
import { homedir as homedir3 } from "os";
|
|
3301
|
-
import { spawn as spawn2 } from "child_process";
|
|
3302
4453
|
async function spawnCommand(args2) {
|
|
3303
4454
|
const spawnAll = args2.includes("--all") || args2.includes("-a");
|
|
3304
4455
|
const name = args2[0];
|
|
@@ -3313,13 +4464,13 @@ async function spawnCommand(args2) {
|
|
|
3313
4464
|
const tracked = readTracked();
|
|
3314
4465
|
const match = tracked.find((t) => t.name === name);
|
|
3315
4466
|
if (!match) {
|
|
3316
|
-
console.error(renderError("Not found", `No tracked process named "${name}". Use ${
|
|
4467
|
+
console.error(renderError("Not found", `No tracked process named "${name}". Use ${pc10.cyan("fennec spawn")} to see available processes, or ${pc10.cyan("fennec start <command> --name <name>")} to create a new one.`));
|
|
3317
4468
|
process.exit(1);
|
|
3318
4469
|
}
|
|
3319
|
-
if (
|
|
4470
|
+
if (isTrackedRunning(match)) {
|
|
3320
4471
|
console.error(`
|
|
3321
|
-
${
|
|
3322
|
-
console.error(` ${
|
|
4472
|
+
${pc10.yellow("\u26A0")} ${pc10.bold(name)} ${pc10.dim("is already running (PID:")} ${match.pid}${pc10.dim(")")}`);
|
|
4473
|
+
console.error(` ${pc10.dim("Use")} ${pc10.cyan(`fennec stop ${name}`)} ${pc10.dim("to stop it first.")}`);
|
|
3323
4474
|
console.error();
|
|
3324
4475
|
process.exit(0);
|
|
3325
4476
|
}
|
|
@@ -3331,18 +4482,18 @@ async function spawnCommand(args2) {
|
|
|
3331
4482
|
}
|
|
3332
4483
|
async function spawnAllStopped() {
|
|
3333
4484
|
const tracked = readTracked();
|
|
3334
|
-
const toSpawn = tracked.filter((t) => !
|
|
4485
|
+
const toSpawn = tracked.filter((t) => !isTrackedRunning(t) && t.command);
|
|
3335
4486
|
if (toSpawn.length === 0) {
|
|
3336
4487
|
console.error(`
|
|
3337
|
-
${
|
|
4488
|
+
${pc10.dim("No stopped processes with saved commands to spawn.")}
|
|
3338
4489
|
`);
|
|
3339
4490
|
return;
|
|
3340
4491
|
}
|
|
3341
4492
|
console.error(`
|
|
3342
|
-
${symbols.fox} ${
|
|
4493
|
+
${symbols.fox} ${pc10.bold(`Spawning ${toSpawn.length} process(es)...`)}
|
|
3343
4494
|
`);
|
|
3344
4495
|
for (const t of toSpawn) {
|
|
3345
|
-
console.error(` ${
|
|
4496
|
+
console.error(` ${pc10.dim("\xB7")} ${pc10.bold(t.name)} ${pc10.dim(`(${t.command})`)}`);
|
|
3346
4497
|
}
|
|
3347
4498
|
console.error();
|
|
3348
4499
|
const spinner = createSpinner("Spawning...");
|
|
@@ -3350,30 +4501,28 @@ async function spawnAllStopped() {
|
|
|
3350
4501
|
let failed = 0;
|
|
3351
4502
|
for (const proc of toSpawn) {
|
|
3352
4503
|
try {
|
|
3353
|
-
const cmdParts = proc
|
|
3354
|
-
const logDir =
|
|
3355
|
-
|
|
3356
|
-
const logFilePath =
|
|
3357
|
-
const child =
|
|
3358
|
-
cwd: proc.cwd,
|
|
3359
|
-
env: { ...process.env },
|
|
3360
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3361
|
-
detached: true
|
|
3362
|
-
});
|
|
4504
|
+
const cmdParts = resolveArgs(proc);
|
|
4505
|
+
const logDir = resolve5(homedir3(), ".fennec", "logs");
|
|
4506
|
+
mkdirSync5(logDir, { recursive: true });
|
|
4507
|
+
const logFilePath = resolve5(logDir, `${proc.name}.log`);
|
|
4508
|
+
const child = spawnDaemon({ cmdParts, name: proc.name, cwd: proc.cwd, logFilePath, env: buildSpawnEnv(proc.env), logMode: proc.logMode });
|
|
3363
4509
|
const pid = child.pid ?? 0;
|
|
3364
|
-
rotateLogFile(logFilePath);
|
|
3365
|
-
const logStream = createWriteStream2(logFilePath, { flags: "a" });
|
|
3366
|
-
if (child.stdout) child.stdout.pipe(logStream);
|
|
3367
|
-
if (child.stderr) child.stderr.pipe(logStream);
|
|
3368
|
-
child.unref();
|
|
3369
4510
|
addTracked({
|
|
3370
4511
|
name: proc.name,
|
|
3371
4512
|
pid,
|
|
3372
4513
|
command: proc.command,
|
|
4514
|
+
args: cmdParts,
|
|
3373
4515
|
port: proc.port,
|
|
3374
4516
|
cwd: proc.cwd,
|
|
3375
|
-
|
|
4517
|
+
env: proc.env,
|
|
4518
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4519
|
+
autoRestart: proc.autoRestart,
|
|
4520
|
+
logMode: proc.logMode
|
|
3376
4521
|
});
|
|
4522
|
+
if (proc.autoRestart) {
|
|
4523
|
+
ensureSupervisorRunning();
|
|
4524
|
+
ensurePersistEnabled();
|
|
4525
|
+
}
|
|
3377
4526
|
spawned++;
|
|
3378
4527
|
} catch {
|
|
3379
4528
|
failed++;
|
|
@@ -3382,10 +4531,10 @@ async function spawnAllStopped() {
|
|
|
3382
4531
|
spinner.stop();
|
|
3383
4532
|
process.stdout.write("\r\x1B[K");
|
|
3384
4533
|
if (spawned > 0) {
|
|
3385
|
-
console.error(` ${
|
|
4534
|
+
console.error(` ${pc10.green("\u2713")} ${pc10.bold(`Spawned ${spawned} process(es)`)}`);
|
|
3386
4535
|
}
|
|
3387
4536
|
if (failed > 0) {
|
|
3388
|
-
console.error(` ${
|
|
4537
|
+
console.error(` ${pc10.red("\u2717")} ${pc10.bold(`${failed} process(es) failed to spawn`)}`);
|
|
3389
4538
|
}
|
|
3390
4539
|
console.error();
|
|
3391
4540
|
await psCommand([]);
|
|
@@ -3394,25 +4543,25 @@ async function spawnList() {
|
|
|
3394
4543
|
const tracked = readTracked();
|
|
3395
4544
|
if (tracked.length === 0) {
|
|
3396
4545
|
console.error(`
|
|
3397
|
-
${
|
|
3398
|
-
console.error(` ${
|
|
4546
|
+
${pc10.dim("No tracked processes found.")}`);
|
|
4547
|
+
console.error(` ${pc10.dim("Start an app first:")} ${pc10.cyan("fennec start <command> --name <name>")}`);
|
|
3399
4548
|
console.error();
|
|
3400
4549
|
return;
|
|
3401
4550
|
}
|
|
3402
|
-
const stopped = tracked.filter((t) => !
|
|
3403
|
-
const running = tracked.filter((t) =>
|
|
4551
|
+
const stopped = tracked.filter((t) => !isTrackedRunning(t));
|
|
4552
|
+
const running = tracked.filter((t) => isTrackedRunning(t));
|
|
3404
4553
|
if (stopped.length === 0) {
|
|
3405
4554
|
console.error(`
|
|
3406
|
-
${
|
|
4555
|
+
${pc10.dim("All tracked processes are running. Nothing to spawn.")}`);
|
|
3407
4556
|
console.error();
|
|
3408
4557
|
return;
|
|
3409
4558
|
}
|
|
3410
4559
|
console.error(`
|
|
3411
|
-
${symbols.fox} ${
|
|
4560
|
+
${symbols.fox} ${pc10.bold("Available to spawn")} ${pc10.dim(`(${stopped.length}/${tracked.length} stopped)`)}`);
|
|
3412
4561
|
if (running.length > 0) {
|
|
3413
|
-
console.error(` ${
|
|
4562
|
+
console.error(` ${pc10.dim("Running (use stop first):")}`);
|
|
3414
4563
|
for (const r of running) {
|
|
3415
|
-
console.error(` ${
|
|
4564
|
+
console.error(` ${pc10.green("\u25CF")} ${pc10.bold(r.name)} ${pc10.dim(`(PID ${r.pid})`)}`);
|
|
3416
4565
|
}
|
|
3417
4566
|
console.error();
|
|
3418
4567
|
}
|
|
@@ -3422,7 +4571,7 @@ async function spawnList() {
|
|
|
3422
4571
|
description: t.command.length > 80 ? t.command.slice(0, 80) + "..." : t.command
|
|
3423
4572
|
})));
|
|
3424
4573
|
if (selected === null) {
|
|
3425
|
-
console.error(` ${
|
|
4574
|
+
console.error(` ${pc10.dim("Cancelled")}`);
|
|
3426
4575
|
return;
|
|
3427
4576
|
}
|
|
3428
4577
|
const match = stopped.find((t) => t.name === selected);
|
|
@@ -3431,38 +4580,36 @@ async function spawnList() {
|
|
|
3431
4580
|
}
|
|
3432
4581
|
}
|
|
3433
4582
|
async function respawnProcess(proc) {
|
|
3434
|
-
const cmdParts = proc
|
|
3435
|
-
const logDir =
|
|
3436
|
-
|
|
3437
|
-
const logFilePath =
|
|
4583
|
+
const cmdParts = resolveArgs(proc);
|
|
4584
|
+
const logDir = resolve5(homedir3(), ".fennec", "logs");
|
|
4585
|
+
mkdirSync5(logDir, { recursive: true });
|
|
4586
|
+
const logFilePath = resolve5(logDir, `${proc.name}.log`);
|
|
3438
4587
|
console.error(`
|
|
3439
|
-
${symbols.fox} ${
|
|
4588
|
+
${symbols.fox} ${pc10.bold("Spawning")} ${renderAppName(proc.name)} ${pc10.dim("(from saved config)")}
|
|
3440
4589
|
`);
|
|
3441
4590
|
console.error(` ${renderKV("Command", proc.command)}`);
|
|
3442
4591
|
if (proc.cwd) console.error(` ${renderKV("Directory", proc.cwd)}`);
|
|
3443
4592
|
if (proc.port) console.error(` ${renderKV("Port", String(proc.port))}`);
|
|
3444
4593
|
try {
|
|
3445
|
-
const child =
|
|
3446
|
-
cwd: proc.cwd,
|
|
3447
|
-
env: { ...process.env },
|
|
3448
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3449
|
-
detached: true
|
|
3450
|
-
});
|
|
4594
|
+
const child = spawnDaemon({ cmdParts, name: proc.name, cwd: proc.cwd, logFilePath, env: buildSpawnEnv(proc.env), logMode: proc.logMode });
|
|
3451
4595
|
const pid = child.pid ?? 0;
|
|
3452
|
-
rotateLogFile(logFilePath);
|
|
3453
|
-
const logStream = createWriteStream2(logFilePath, { flags: "a" });
|
|
3454
|
-
if (child.stdout) child.stdout.pipe(logStream);
|
|
3455
|
-
if (child.stderr) child.stderr.pipe(logStream);
|
|
3456
|
-
child.unref();
|
|
3457
4596
|
addTracked({
|
|
3458
4597
|
name: proc.name,
|
|
3459
4598
|
pid,
|
|
3460
4599
|
command: proc.command,
|
|
4600
|
+
args: cmdParts,
|
|
3461
4601
|
port: proc.port,
|
|
3462
4602
|
cwd: proc.cwd,
|
|
3463
|
-
|
|
4603
|
+
env: proc.env,
|
|
4604
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4605
|
+
autoRestart: proc.autoRestart,
|
|
4606
|
+
logMode: proc.logMode
|
|
3464
4607
|
});
|
|
3465
|
-
|
|
4608
|
+
if (proc.autoRestart) {
|
|
4609
|
+
ensureSupervisorRunning();
|
|
4610
|
+
ensurePersistEnabled();
|
|
4611
|
+
}
|
|
4612
|
+
console.error(` ${pc10.green("\u2713")} ${pc10.bold(proc.name)} ${pc10.dim(`spawned (PID: ${pid})`)}`);
|
|
3466
4613
|
await psCommand([]);
|
|
3467
4614
|
} catch (error) {
|
|
3468
4615
|
console.error(renderError(`Failed to spawn ${proc.name}`, String(error)));
|
|
@@ -3471,11 +4618,10 @@ async function respawnProcess(proc) {
|
|
|
3471
4618
|
}
|
|
3472
4619
|
|
|
3473
4620
|
// src/commands/restart.ts
|
|
3474
|
-
import
|
|
3475
|
-
import { mkdirSync as
|
|
3476
|
-
import { resolve as
|
|
4621
|
+
import pc11 from "picocolors";
|
|
4622
|
+
import { mkdirSync as mkdirSync6 } from "fs";
|
|
4623
|
+
import { resolve as resolve6 } from "path";
|
|
3477
4624
|
import { homedir as homedir4 } from "os";
|
|
3478
|
-
import { spawn as spawn3 } from "child_process";
|
|
3479
4625
|
async function restartCommand(args2) {
|
|
3480
4626
|
const name = args2[0];
|
|
3481
4627
|
if (!name) {
|
|
@@ -3500,9 +4646,9 @@ async function restartCommand(args2) {
|
|
|
3500
4646
|
targetPid = processes[0].pid;
|
|
3501
4647
|
}
|
|
3502
4648
|
}
|
|
3503
|
-
const confirmed = await confirmPrompt(`Restart ${
|
|
4649
|
+
const confirmed = await confirmPrompt(`Restart ${pc11.bold(`${name} (PID ${targetPid})`)}?`, false);
|
|
3504
4650
|
if (!confirmed) {
|
|
3505
|
-
console.error(` ${
|
|
4651
|
+
console.error(` ${pc11.dim("Cancelled")}`);
|
|
3506
4652
|
return;
|
|
3507
4653
|
}
|
|
3508
4654
|
const spinner = createSpinner(`Stopping PID ${targetPid}...`);
|
|
@@ -3521,86 +4667,581 @@ async function restartCommand(args2) {
|
|
|
3521
4667
|
if (trackedEntry) {
|
|
3522
4668
|
const respawnSpinner = createSpinner(`Re-spawning ${trackedEntry.name}...`);
|
|
3523
4669
|
try {
|
|
3524
|
-
const cmdParts = trackedEntry
|
|
3525
|
-
const logDir =
|
|
3526
|
-
|
|
3527
|
-
const logFilePath =
|
|
3528
|
-
const child =
|
|
3529
|
-
cwd: trackedEntry.cwd,
|
|
3530
|
-
env: { ...process.env },
|
|
3531
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3532
|
-
detached: true
|
|
3533
|
-
});
|
|
3534
|
-
rotateLogFile(logFilePath);
|
|
3535
|
-
const logStream = createWriteStream3(logFilePath, { flags: "a" });
|
|
3536
|
-
if (child.stdout) child.stdout.pipe(logStream);
|
|
3537
|
-
if (child.stderr) child.stderr.pipe(logStream);
|
|
3538
|
-
child.unref();
|
|
4670
|
+
const cmdParts = resolveArgs(trackedEntry);
|
|
4671
|
+
const logDir = resolve6(homedir4(), ".fennec", "logs");
|
|
4672
|
+
mkdirSync6(logDir, { recursive: true });
|
|
4673
|
+
const logFilePath = resolve6(logDir, `${trackedEntry.name}.log`);
|
|
4674
|
+
const child = spawnDaemon({ cmdParts, name: trackedEntry.name, cwd: trackedEntry.cwd, logFilePath, env: buildSpawnEnv(trackedEntry.env), logMode: trackedEntry.logMode });
|
|
3539
4675
|
removeTrackedByPid(targetPid);
|
|
3540
4676
|
addTracked({
|
|
3541
4677
|
name: trackedEntry.name,
|
|
3542
4678
|
pid: child.pid ?? 0,
|
|
3543
4679
|
command: trackedEntry.command,
|
|
4680
|
+
args: cmdParts,
|
|
3544
4681
|
port: trackedEntry.port,
|
|
3545
4682
|
cwd: trackedEntry.cwd,
|
|
3546
|
-
|
|
4683
|
+
env: trackedEntry.env,
|
|
4684
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4685
|
+
autoRestart: trackedEntry.autoRestart,
|
|
4686
|
+
logMode: trackedEntry.logMode
|
|
3547
4687
|
});
|
|
3548
4688
|
respawnSpinner.succeed(`${trackedEntry.name} restarted (PID: ${child.pid})`);
|
|
3549
4689
|
} catch (error) {
|
|
3550
4690
|
respawnSpinner.fail(`Failed to re-spawn ${trackedEntry.name}: ${String(error)}`);
|
|
3551
|
-
console.error(` ${
|
|
4691
|
+
console.error(` ${pc11.dim("Config preserved. Re-run manually:")} ${renderCommand(trackedEntry.command)}`);
|
|
4692
|
+
}
|
|
4693
|
+
} else {
|
|
4694
|
+
console.error(` ${pc11.dim("No tracked config found. Re-run manually:")} ${renderCommand(name)}`);
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
// src/commands/adopt.ts
|
|
4699
|
+
import pc12 from "picocolors";
|
|
4700
|
+
function adoptCommand(args2) {
|
|
4701
|
+
const pidArg = args2.find((a) => /^\d+$/.test(a));
|
|
4702
|
+
if (!pidArg) {
|
|
4703
|
+
console.error(renderError("Missing PID", `Usage: fennec adopt <pid> [--name <name>] [--port <port>]`));
|
|
4704
|
+
process.exit(1);
|
|
4705
|
+
}
|
|
4706
|
+
const pid = parseInt(pidArg, 10);
|
|
4707
|
+
const nameIdx = args2.indexOf("--name");
|
|
4708
|
+
const name = nameIdx !== -1 ? args2[nameIdx + 1] : void 0;
|
|
4709
|
+
const portIdx = args2.indexOf("--port");
|
|
4710
|
+
const port = portIdx !== -1 ? parseInt(args2[portIdx + 1] ?? "", 10) : void 0;
|
|
4711
|
+
const entry = adoptProcess(pid, { name, port: Number.isNaN(port) ? void 0 : port });
|
|
4712
|
+
if (!entry) {
|
|
4713
|
+
console.error(renderError("Cannot adopt", `No running process with PID ${pid}`));
|
|
4714
|
+
process.exit(1);
|
|
4715
|
+
}
|
|
4716
|
+
console.error(`
|
|
4717
|
+
${symbols.fox} ${pc12.green("\u2713")} ${pc12.bold("Adopted")} ${pc12.bold(entry.name)} ${pc12.dim(`(PID ${pid})`)}`);
|
|
4718
|
+
if (entry.port) console.error(` ${pc12.dim("port")} ${pc12.yellow(`:${entry.port}`)}`);
|
|
4719
|
+
if (entry.command) console.error(` ${pc12.dim("command")} ${entry.command}`);
|
|
4720
|
+
console.error(` ${pc12.dim("status")} now tracked + supervised by the supervisor`);
|
|
4721
|
+
console.error(` ${pc12.dim("logs")} ${pc12.cyan(logFilePathFor(entry.name))}
|
|
4722
|
+
`);
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4725
|
+
// src/commands/inspect.ts
|
|
4726
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
4727
|
+
import pc13 from "picocolors";
|
|
4728
|
+
|
|
4729
|
+
// src/utils/redact.ts
|
|
4730
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
4731
|
+
var SECRET_PATTERNS = [
|
|
4732
|
+
// Bearer / auth tokens
|
|
4733
|
+
{ name: "bearer", re: /\bBearer\s+[A-Za-z0-9._\-]+/gi },
|
|
4734
|
+
// Authorization: <scheme> <token>
|
|
4735
|
+
{ name: "authorization", re: /\bAuthorization\s*:\s*\S+/gi },
|
|
4736
|
+
// Generic API keys
|
|
4737
|
+
{ name: "apikey", re: /\b(api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key)\s*[:=]\s*\S+/gi },
|
|
4738
|
+
// AWS
|
|
4739
|
+
{ name: "aws", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
4740
|
+
// Slack tokens
|
|
4741
|
+
{ name: "slack", re: /\bxox[baprs]-[0-9A-Za-z\-]{10,}/g },
|
|
4742
|
+
// Stripe
|
|
4743
|
+
{ name: "stripe", re: /\b(sk|rk|pk)_(live|test)_[0-9A-Za-z]{16,}\b/g },
|
|
4744
|
+
// JWT
|
|
4745
|
+
{ name: "jwt", re: /\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]*/g },
|
|
4746
|
+
// Google API
|
|
4747
|
+
{ name: "google", re: /\bAIza[0-9A-Za-z_\-]{35}\b/g },
|
|
4748
|
+
// GitHub PAT
|
|
4749
|
+
{ name: "github", re: /\bgh[pousr]_[0-9A-Za-z]{36,}\b/g },
|
|
4750
|
+
// Generic long hex/alpha tokens (>=32 chars) in common contexts
|
|
4751
|
+
{ name: "token", re: /\b(token|secret|password|passwd|pwd|client[_-]?secret)\s*[:=]\s*\S+/gi },
|
|
4752
|
+
// Connection strings with credentials
|
|
4753
|
+
{ name: "connstr", re: /\b(mongodb(\+srv)?|postgres(ql)?|mysql|redis|amqp|sqlserver):\/\/[^\s:]+:[^\s@]+@/gi },
|
|
4754
|
+
// Private key blocks
|
|
4755
|
+
{ name: "pem", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g }
|
|
4756
|
+
];
|
|
4757
|
+
function redactLine(line, opts = {}) {
|
|
4758
|
+
if (opts.enabled === false) return line;
|
|
4759
|
+
let out = line;
|
|
4760
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
4761
|
+
out = out.replace(re, (m) => {
|
|
4762
|
+
const head = m.slice(0, Math.min(m.length, name.length + 4));
|
|
4763
|
+
return `${head}\u2026[REDACTED]`;
|
|
4764
|
+
});
|
|
4765
|
+
}
|
|
4766
|
+
return out;
|
|
4767
|
+
}
|
|
4768
|
+
function redactionCount(line) {
|
|
4769
|
+
let n = 0;
|
|
4770
|
+
for (const { re } of SECRET_PATTERNS) {
|
|
4771
|
+
const m = line.match(re);
|
|
4772
|
+
if (m) n += m.length;
|
|
4773
|
+
}
|
|
4774
|
+
return n;
|
|
4775
|
+
}
|
|
4776
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
4777
|
+
function stripAnsi(s) {
|
|
4778
|
+
return s.replace(ANSI_RE, "");
|
|
4779
|
+
}
|
|
4780
|
+
var LEVEL_RE = [
|
|
4781
|
+
{ level: "error", re: /\b(ERROR|FATAL|CRITICAL|EXCEPTION|UNCAUGHT)\b/i },
|
|
4782
|
+
{ level: "warn", re: /\b(WARN|WARNING)\b/i },
|
|
4783
|
+
{ level: "info", re: /\b(INFO|NOTICE)\b/i },
|
|
4784
|
+
{ level: "debug", re: /\b(DEBUG|TRACE)\b/i }
|
|
4785
|
+
];
|
|
4786
|
+
function classifyLevel(line) {
|
|
4787
|
+
for (const { level, re } of LEVEL_RE) {
|
|
4788
|
+
if (re.test(line)) return level;
|
|
4789
|
+
}
|
|
4790
|
+
if (/\b(error|fail|failed|failure|denied|reject)\b/i.test(line)) return "error";
|
|
4791
|
+
if (/\b(warn)\b/i.test(line)) return "warn";
|
|
4792
|
+
return "other";
|
|
4793
|
+
}
|
|
4794
|
+
function extractTimestamp(line) {
|
|
4795
|
+
const m = line.match(/^\s*\[?(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\]?/);
|
|
4796
|
+
if (m) return m[1];
|
|
4797
|
+
const trimmed = line.trimStart();
|
|
4798
|
+
if (trimmed.startsWith("{")) {
|
|
4799
|
+
try {
|
|
4800
|
+
const obj = JSON.parse(line);
|
|
4801
|
+
const ts = obj.ts ?? obj.time ?? obj.timestamp;
|
|
4802
|
+
if (typeof ts === "string") return ts;
|
|
4803
|
+
if (typeof ts === "number") return new Date(ts).toISOString();
|
|
4804
|
+
} catch {
|
|
3552
4805
|
}
|
|
4806
|
+
}
|
|
4807
|
+
return null;
|
|
4808
|
+
}
|
|
4809
|
+
function readLogLines(path, opts = {}) {
|
|
4810
|
+
if (!existsSync6(path)) return [];
|
|
4811
|
+
const raw = readFileSync5(path, "utf-8");
|
|
4812
|
+
let lines = raw.split("\n").filter(Boolean);
|
|
4813
|
+
const now = Date.now();
|
|
4814
|
+
if (opts.sinceMs && opts.parseTimestamp) {
|
|
4815
|
+
lines = lines.filter((l) => {
|
|
4816
|
+
const ts = extractTimestamp(l);
|
|
4817
|
+
if (ts === null) return true;
|
|
4818
|
+
const t = Date.parse(ts);
|
|
4819
|
+
return !isNaN(t) && now - t <= opts.sinceMs;
|
|
4820
|
+
});
|
|
4821
|
+
}
|
|
4822
|
+
if (opts.redact === false) {
|
|
4823
|
+
lines = lines.map(stripAnsi);
|
|
3553
4824
|
} else {
|
|
3554
|
-
|
|
4825
|
+
lines = lines.map((l) => stripAnsi(redactLine(l)));
|
|
4826
|
+
}
|
|
4827
|
+
if (opts.tail && opts.tail > 0 && lines.length > opts.tail) {
|
|
4828
|
+
lines = lines.slice(-opts.tail);
|
|
4829
|
+
}
|
|
4830
|
+
return lines;
|
|
4831
|
+
}
|
|
4832
|
+
function parseDuration(input) {
|
|
4833
|
+
const m = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
4834
|
+
if (!m) return null;
|
|
4835
|
+
const n = parseInt(m[1], 10);
|
|
4836
|
+
const unit = m[2].toLowerCase();
|
|
4837
|
+
const mul = unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5;
|
|
4838
|
+
return n * mul;
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4841
|
+
// src/commands/inspect.ts
|
|
4842
|
+
var DEFAULT_TAIL = 40;
|
|
4843
|
+
var MAX_TAIL = 200;
|
|
4844
|
+
function readMetrics(pid) {
|
|
4845
|
+
const m = {};
|
|
4846
|
+
try {
|
|
4847
|
+
const status = readFileSync6(`/proc/${pid}/status`, "utf-8");
|
|
4848
|
+
const rssLine = status.split("\n").find((l) => l.startsWith("VmRSS:"));
|
|
4849
|
+
if (rssLine) {
|
|
4850
|
+
const kb = parseInt(rssLine.replace(/[^\d]/g, ""), 10);
|
|
4851
|
+
if (!isNaN(kb)) m.rssMb = Math.round(kb / 1024 * 10) / 10;
|
|
4852
|
+
}
|
|
4853
|
+
} catch {
|
|
4854
|
+
}
|
|
4855
|
+
return m;
|
|
4856
|
+
}
|
|
4857
|
+
async function inspectCommand(args2) {
|
|
4858
|
+
const target = args2[0];
|
|
4859
|
+
if (!target || target.startsWith("--")) {
|
|
4860
|
+
console.error(pc13.dim(`Usage: fennec inspect <name|pid> [--plain] [--tail N] [--since 10m]`));
|
|
4861
|
+
process.exit(1);
|
|
4862
|
+
}
|
|
4863
|
+
const plain = args2.includes("--plain");
|
|
4864
|
+
const tailIndex = args2.indexOf("--tail");
|
|
4865
|
+
const tail = tailIndex !== -1 ? Math.min(parseInt(args2[tailIndex + 1], 10) || DEFAULT_TAIL, MAX_TAIL) : DEFAULT_TAIL;
|
|
4866
|
+
const sinceIndex = args2.indexOf("--since");
|
|
4867
|
+
const sinceMs = sinceIndex !== -1 ? parseDuration(args2[sinceIndex + 1]) : void 0;
|
|
4868
|
+
if (sinceIndex !== -1 && sinceMs === void 0) {
|
|
4869
|
+
console.error(pc13.red(`Invalid --since. Use e.g. 10m, 1h, 30s.`));
|
|
4870
|
+
process.exit(1);
|
|
4871
|
+
}
|
|
4872
|
+
const tracked = readTracked();
|
|
4873
|
+
const proc = tracked.find((t) => t.name === target || parseInt(target, 10) === t.pid && !isNaN(parseInt(target, 10)));
|
|
4874
|
+
if (!proc) {
|
|
4875
|
+
console.error(pc13.red(`No tracked app named "${target}".`));
|
|
4876
|
+
process.exit(1);
|
|
4877
|
+
}
|
|
4878
|
+
const running = isTrackedRunning(proc);
|
|
4879
|
+
const logPath = logFilePathFor(proc.name);
|
|
4880
|
+
const lines = readLogLines(logPath, { tail: Math.max(tail, sinceMs ? MAX_TAIL : 0), sinceMs: sinceMs ?? void 0, redact: true, parseTimestamp: true });
|
|
4881
|
+
const recent = lines.slice(-tail);
|
|
4882
|
+
const errorLines = lines.filter((l) => classifyLevel(l) === "error").slice(-15);
|
|
4883
|
+
const redactedLines = recent.reduce((acc, l) => acc + (redactionCount(stripAnsi(l)) > 0 ? 1 : 0), 0);
|
|
4884
|
+
const metrics = running ? readMetrics(proc.pid) : {};
|
|
4885
|
+
let portHealthy = null;
|
|
4886
|
+
if (proc.port) {
|
|
4887
|
+
try {
|
|
4888
|
+
portHealthy = await checkPort(proc.port);
|
|
4889
|
+
} catch {
|
|
4890
|
+
portHealthy = false;
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
const startedAt = Date.parse(proc.startedAt);
|
|
4894
|
+
const uptimeSec = running && !isNaN(startedAt) ? Math.floor((Date.now() - startedAt) / 1e3) : 0;
|
|
4895
|
+
if (plain) {
|
|
4896
|
+
const dot = running ? pc13.green("\u25CF") : pc13.red("\u25CB");
|
|
4897
|
+
const portStr = proc.port ? ` :${proc.port}${portHealthy === false ? pc13.red(" (port down!)") : portHealthy ? pc13.green(" (up)") : ""}` : "";
|
|
4898
|
+
console.error(`
|
|
4899
|
+
${symbols.fox} ${renderAppName(proc.name)}${portStr} ${dot} ${running ? pc13.green(`running PID ${proc.pid}`) : pc13.red("stopped")}`);
|
|
4900
|
+
console.error(` ${pc13.dim("uptime")} ${pc13.cyan(uptimeSec ? formatUptime(uptimeSec) : "\u2014")} ${pc13.dim("rss")} ${pc13.cyan(metrics.rssMb ? `${metrics.rssMb}MB` : "\u2014")} ${pc13.dim("errors(last)")} ${pc13.cyan(String(errorLines.length))} ${pc13.dim("redacted")} ${pc13.cyan(String(redactedLines))}`);
|
|
4901
|
+
if (errorLines.length) {
|
|
4902
|
+
console.error(`
|
|
4903
|
+
${pc13.bold(pc13.red("Recent errors:"))}`);
|
|
4904
|
+
for (const l of errorLines.slice(-8)) console.error(` ${pc13.red(stripAnsi(l).slice(0, 300))}`);
|
|
4905
|
+
}
|
|
4906
|
+
console.error();
|
|
4907
|
+
return;
|
|
4908
|
+
}
|
|
4909
|
+
const payload = {
|
|
4910
|
+
name: proc.name,
|
|
4911
|
+
running,
|
|
4912
|
+
pid: running ? proc.pid ?? null : null,
|
|
4913
|
+
port: proc.port ?? null,
|
|
4914
|
+
portHealthy,
|
|
4915
|
+
autoRestart: proc.autoRestart ?? false,
|
|
4916
|
+
uptimeSec,
|
|
4917
|
+
metrics,
|
|
4918
|
+
logTail: recent,
|
|
4919
|
+
errorCount: errorLines.length,
|
|
4920
|
+
errors: errorLines,
|
|
4921
|
+
redacted: true,
|
|
4922
|
+
redactedLines,
|
|
4923
|
+
note: "Secrets redacted by default. Bounded snapshot for AI observation."
|
|
4924
|
+
};
|
|
4925
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
4926
|
+
}
|
|
4927
|
+
|
|
4928
|
+
// src/commands/dev.ts
|
|
4929
|
+
import pc14 from "picocolors";
|
|
4930
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
4931
|
+
import { resolve as resolve7, dirname as dirname5 } from "path";
|
|
4932
|
+
import "os";
|
|
4933
|
+
import yaml from "js-yaml";
|
|
4934
|
+
function loadConfig(path) {
|
|
4935
|
+
if (!existsSync8(path)) {
|
|
4936
|
+
console.error(renderError("Config not found", path));
|
|
4937
|
+
process.exit(1);
|
|
4938
|
+
}
|
|
4939
|
+
try {
|
|
4940
|
+
const raw = readFileSync7(path, "utf-8");
|
|
4941
|
+
const parsed = yaml.load(raw);
|
|
4942
|
+
return parsed ?? {};
|
|
4943
|
+
} catch (err) {
|
|
4944
|
+
console.error(renderError("Failed to parse config", String(err)));
|
|
4945
|
+
process.exit(1);
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
function resolveCwd(baseDir, cwd) {
|
|
4949
|
+
if (!cwd) return baseDir;
|
|
4950
|
+
return resolve7(baseDir, cwd);
|
|
4951
|
+
}
|
|
4952
|
+
async function waitForPort(port, timeoutMs = 3e4) {
|
|
4953
|
+
const deadline = Date.now() + timeoutMs;
|
|
4954
|
+
while (Date.now() < deadline) {
|
|
4955
|
+
try {
|
|
4956
|
+
if (await checkPort(port)) return true;
|
|
4957
|
+
} catch {
|
|
4958
|
+
}
|
|
4959
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4960
|
+
}
|
|
4961
|
+
return false;
|
|
4962
|
+
}
|
|
4963
|
+
function configMatches(existing, desired) {
|
|
4964
|
+
if (existing.command !== desired.command) return false;
|
|
4965
|
+
if (JSON.stringify(existing.args ?? []) !== JSON.stringify(desired.args)) return false;
|
|
4966
|
+
if (existing.cwd !== desired.cwd) return false;
|
|
4967
|
+
if ((existing.port ?? null) !== (desired.port ?? null)) return false;
|
|
4968
|
+
if (existing.logMode !== desired.logMode) return false;
|
|
4969
|
+
if ((existing.autoRestart ?? true) !== desired.autoRestart) return false;
|
|
4970
|
+
if ((existing.healthCheck ?? null) !== (desired.healthCheck ?? null)) return false;
|
|
4971
|
+
if (JSON.stringify(existing.env ?? {}) !== JSON.stringify(desired.env)) return false;
|
|
4972
|
+
return true;
|
|
4973
|
+
}
|
|
4974
|
+
async function startApp(app, baseDir) {
|
|
4975
|
+
const cwd = resolveCwd(baseDir, app.cwd);
|
|
4976
|
+
const cmdParts = [app.command, ...app.args ?? []];
|
|
4977
|
+
const logFilePath = logFilePathFor(app.name);
|
|
4978
|
+
const env = { ...app.env ?? {} };
|
|
4979
|
+
const port = app.waitForPort ?? app.port;
|
|
4980
|
+
const restart = app.restart ?? true;
|
|
4981
|
+
const logMode = app.jsonl ? "jsonl" : "text";
|
|
4982
|
+
const desired = {
|
|
4983
|
+
command: cmdParts.join(" "),
|
|
4984
|
+
args: cmdParts,
|
|
4985
|
+
cwd,
|
|
4986
|
+
port,
|
|
4987
|
+
env,
|
|
4988
|
+
logMode,
|
|
4989
|
+
autoRestart: restart,
|
|
4990
|
+
healthCheck: app.healthCheck
|
|
4991
|
+
};
|
|
4992
|
+
const existing = readTracked().find((t) => t.name === app.name);
|
|
4993
|
+
if (existing && isTrackedRunning(existing) && configMatches(existing, desired)) {
|
|
4994
|
+
return "skipped";
|
|
4995
|
+
}
|
|
4996
|
+
const wasRunning = !!(existing && isTrackedRunning(existing));
|
|
4997
|
+
if (wasRunning) {
|
|
4998
|
+
try {
|
|
4999
|
+
process.kill(existing.pid, "SIGTERM");
|
|
5000
|
+
} catch {
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
5003
|
+
if (port && !(existing && isTrackedRunning(existing))) {
|
|
5004
|
+
const adopted = adoptExternalOnPort(port, app.name);
|
|
5005
|
+
if (adopted) {
|
|
5006
|
+
return "adopted";
|
|
5007
|
+
}
|
|
5008
|
+
if (await checkPort(port)) {
|
|
5009
|
+
console.error(
|
|
5010
|
+
` ${pc14.yellow("\u26A0")} ${pc14.dim(`port :${port} already responding \u2014 ${app.name} may fail to bind (EADDRINUSE)`)}`
|
|
5011
|
+
);
|
|
5012
|
+
}
|
|
5013
|
+
}
|
|
5014
|
+
const child = spawnDaemon({
|
|
5015
|
+
cmdParts,
|
|
5016
|
+
name: app.name,
|
|
5017
|
+
cwd,
|
|
5018
|
+
logFilePath,
|
|
5019
|
+
env: buildSpawnEnv(env),
|
|
5020
|
+
logMode
|
|
5021
|
+
});
|
|
5022
|
+
addTracked({
|
|
5023
|
+
name: app.name,
|
|
5024
|
+
pid: child.pid ?? 0,
|
|
5025
|
+
command: desired.command,
|
|
5026
|
+
args: cmdParts,
|
|
5027
|
+
port,
|
|
5028
|
+
cwd,
|
|
5029
|
+
env,
|
|
5030
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5031
|
+
autoRestart: restart,
|
|
5032
|
+
logMode,
|
|
5033
|
+
flapping: false,
|
|
5034
|
+
healthCheck: app.healthCheck
|
|
5035
|
+
});
|
|
5036
|
+
return wasRunning ? "restarted" : "started";
|
|
5037
|
+
}
|
|
5038
|
+
async function devCommand(args2) {
|
|
5039
|
+
const sub = args2[0] ?? "up";
|
|
5040
|
+
const configIndex = args2.indexOf("--config");
|
|
5041
|
+
const configPath = configIndex !== -1 ? resolve7(args2[configIndex + 1]) : resolve7(process.cwd(), "fennec.config.yaml");
|
|
5042
|
+
if (sub === "down") {
|
|
5043
|
+
return devDown();
|
|
5044
|
+
}
|
|
5045
|
+
if (sub === "status") {
|
|
5046
|
+
return devStatus();
|
|
5047
|
+
}
|
|
5048
|
+
if (sub === "restart") {
|
|
5049
|
+
return devRestart(args2.slice(1));
|
|
5050
|
+
}
|
|
5051
|
+
if (sub !== "up") {
|
|
5052
|
+
console.error(renderError("Unknown sub-command", `"${sub}" \u2014 use: up | down | status | restart`));
|
|
5053
|
+
process.exit(1);
|
|
5054
|
+
}
|
|
5055
|
+
const config = loadConfig(configPath);
|
|
5056
|
+
const apps = config.apps ?? [];
|
|
5057
|
+
if (apps.length === 0) {
|
|
5058
|
+
console.error(renderError("No apps defined", `Add an "apps:" list to ${configPath}`));
|
|
5059
|
+
process.exit(1);
|
|
5060
|
+
}
|
|
5061
|
+
const baseDir = dirname5(configPath);
|
|
5062
|
+
const started = /* @__PURE__ */ new Set();
|
|
5063
|
+
const ordered = topoOrder(apps);
|
|
5064
|
+
const portOwner = /* @__PURE__ */ new Map();
|
|
5065
|
+
for (const a of apps) {
|
|
5066
|
+
const p = a.waitForPort ?? a.port;
|
|
5067
|
+
if (p) {
|
|
5068
|
+
const prev = portOwner.get(p);
|
|
5069
|
+
if (prev) {
|
|
5070
|
+
console.error(renderError("Port conflict", `apps "${prev}" and "${a.name}" both declare port :${p}`));
|
|
5071
|
+
process.exit(1);
|
|
5072
|
+
}
|
|
5073
|
+
portOwner.set(p, a.name);
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5076
|
+
console.error(`
|
|
5077
|
+
${symbols.fox} ${pc14.bold("fennec dev up")} ${pc14.dim(`\u2014 ${apps.length} app(s) from ${configPath}`)}`);
|
|
5078
|
+
ensureSupervisorRunning();
|
|
5079
|
+
ensurePersistEnabled();
|
|
5080
|
+
for (const app of ordered) {
|
|
5081
|
+
for (const dep of app.dependsOn ?? []) {
|
|
5082
|
+
const depApp = apps.find((a) => a.name === dep);
|
|
5083
|
+
const port = depApp?.waitForPort ?? depApp?.port;
|
|
5084
|
+
if (port) {
|
|
5085
|
+
const sp = createSpinner(`Waiting for ${dep} :${port}...`);
|
|
5086
|
+
const ok = await waitForPort(port);
|
|
5087
|
+
sp.stop();
|
|
5088
|
+
if (!ok) console.error(` ${pc14.yellow("\u26A0")} ${pc14.dim(`${dep} :${port} not ready in time (continuing)`)}`);
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
const res = await startApp(app, baseDir);
|
|
5092
|
+
const pid = readTracked().find((t) => t.name === app.name)?.pid ?? "?";
|
|
5093
|
+
const tag = res === "skipped" ? pc14.dim("already running (skipped)") : res === "restarted" ? pc14.yellow(`restarted (PID ${pid})`) : res === "adopted" ? pc14.cyan(`adopted external process on :${app.port ?? app.waitForPort}`) : pc14.dim(`started (PID ${pid})`);
|
|
5094
|
+
console.error(` ${pc14.green("\u2713")} ${pc14.bold(app.name)} ${tag}`);
|
|
5095
|
+
}
|
|
5096
|
+
console.error(`
|
|
5097
|
+
${pc14.green("\u2713")} Stack up. Observe with ${pc14.cyan("fennec observe")} or ${pc14.cyan("fennec dev status")}.`);
|
|
5098
|
+
console.error(` ${pc14.dim("Apps auto-restart (supervisor) + survive reboot (persist).")}
|
|
5099
|
+
`);
|
|
5100
|
+
}
|
|
5101
|
+
function devDown() {
|
|
5102
|
+
const tracked = readTracked().filter((t) => t.autoRestart);
|
|
5103
|
+
if (tracked.length === 0) {
|
|
5104
|
+
console.error(`
|
|
5105
|
+
${pc14.dim("No supervised apps to stop.")}
|
|
5106
|
+
`);
|
|
5107
|
+
return;
|
|
5108
|
+
}
|
|
5109
|
+
console.error(`
|
|
5110
|
+
${symbols.fox} ${pc14.bold("fennec dev down")} ${pc14.dim(`\u2014 stopping ${tracked.length} app(s)`)}`);
|
|
5111
|
+
for (const t of tracked) {
|
|
5112
|
+
try {
|
|
5113
|
+
if (isTrackedRunning(t)) process.kill(t.pid, "SIGTERM");
|
|
5114
|
+
} catch {
|
|
5115
|
+
}
|
|
5116
|
+
t.autoRestart = false;
|
|
5117
|
+
addTracked({ ...t, autoRestart: false });
|
|
5118
|
+
console.error(` ${pc14.red("\u25A0")} ${pc14.bold(t.name)}`);
|
|
5119
|
+
}
|
|
5120
|
+
console.error(`
|
|
5121
|
+
${pc14.green("\u2713")} Stack stopped (auto-restart disabled). Use ${pc14.cyan("fennec dev up")} to bring it back.
|
|
5122
|
+
`);
|
|
5123
|
+
}
|
|
5124
|
+
function devRestart(names) {
|
|
5125
|
+
ensureSupervisorRunning();
|
|
5126
|
+
ensurePersistEnabled();
|
|
5127
|
+
const tracked = readTracked();
|
|
5128
|
+
const targets = names.length ? tracked.filter((t) => names.includes(t.name)) : tracked.filter((t) => t.autoRestart);
|
|
5129
|
+
const missing = names.filter((n) => !tracked.some((t) => t.name === n));
|
|
5130
|
+
if (missing.length) {
|
|
5131
|
+
console.error(renderError("Not tracked", `No such app(s): ${missing.join(", ")} \u2014 run "fennec dev status" to list tracked apps.`));
|
|
5132
|
+
}
|
|
5133
|
+
if (targets.length === 0) {
|
|
5134
|
+
console.error(`
|
|
5135
|
+
${pc14.dim("Nothing to restart.")}
|
|
5136
|
+
`);
|
|
5137
|
+
return;
|
|
5138
|
+
}
|
|
5139
|
+
console.error(`
|
|
5140
|
+
${symbols.fox} ${pc14.bold("fennec dev restart")} ${pc14.dim(`\u2014 ${targets.length} app(s)`)}`);
|
|
5141
|
+
for (const t of targets) {
|
|
5142
|
+
try {
|
|
5143
|
+
const newPid = respawnTracked(t, "manual");
|
|
5144
|
+
console.error(` ${pc14.green("\u2713")} ${pc14.bold(t.name)} ${pc14.dim(`restarted (PID ${newPid})`)}`);
|
|
5145
|
+
} catch (err) {
|
|
5146
|
+
console.error(` ${pc14.red("\u2717")} ${pc14.bold(t.name)} ${pc14.dim(`restart failed: ${String(err)}`)}`);
|
|
5147
|
+
}
|
|
5148
|
+
}
|
|
5149
|
+
console.error();
|
|
5150
|
+
}
|
|
5151
|
+
function devStatus() {
|
|
5152
|
+
const tracked = readTracked();
|
|
5153
|
+
console.error(`
|
|
5154
|
+
${symbols.fox} ${pc14.bold("fennec dev status")}`);
|
|
5155
|
+
if (tracked.length === 0) {
|
|
5156
|
+
console.error(` ${pc14.dim("Nothing tracked.")}
|
|
5157
|
+
`);
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
5160
|
+
for (const t of tracked) {
|
|
5161
|
+
const running = isTrackedRunning(t);
|
|
5162
|
+
const dot = running ? pc14.green("\u25CF") : pc14.red("\u25CB");
|
|
5163
|
+
const state = running ? pc14.green(`running (PID ${t.pid})`) : pc14.red("stopped");
|
|
5164
|
+
const portStr = t.port ? ` ${pc14.yellow(`:${t.port}`)}` : "";
|
|
5165
|
+
const cause = t.restartCause ? pc14.dim(` \xB7 last-restart: ${t.restartCause}`) : "";
|
|
5166
|
+
const flap = t.flapping ? pc14.red(" \xB7 \u26A0 flapping") : "";
|
|
5167
|
+
console.error(` ${dot} ${pc14.bold(t.name)}${portStr} ${pc14.dim("\u2014")} ${state}${cause}${flap}`);
|
|
3555
5168
|
}
|
|
5169
|
+
console.error();
|
|
5170
|
+
}
|
|
5171
|
+
function topoOrder(apps) {
|
|
5172
|
+
const byName = new Map(apps.map((a) => [a.name, a]));
|
|
5173
|
+
const visited = /* @__PURE__ */ new Set();
|
|
5174
|
+
const out = [];
|
|
5175
|
+
const visit = (app, stack) => {
|
|
5176
|
+
if (visited.has(app.name)) return;
|
|
5177
|
+
if (stack.has(app.name)) return;
|
|
5178
|
+
stack.add(app.name);
|
|
5179
|
+
for (const dep of app.dependsOn ?? []) {
|
|
5180
|
+
const d = byName.get(dep);
|
|
5181
|
+
if (d) visit(d, stack);
|
|
5182
|
+
}
|
|
5183
|
+
stack.delete(app.name);
|
|
5184
|
+
visited.add(app.name);
|
|
5185
|
+
out.push(app);
|
|
5186
|
+
};
|
|
5187
|
+
for (const app of apps) visit(app, /* @__PURE__ */ new Set());
|
|
5188
|
+
return out;
|
|
3556
5189
|
}
|
|
3557
5190
|
|
|
3558
5191
|
// src/commands/log.ts
|
|
3559
|
-
import { existsSync as
|
|
3560
|
-
import {
|
|
3561
|
-
import
|
|
3562
|
-
|
|
3563
|
-
import pc10 from "picocolors";
|
|
5192
|
+
import { existsSync as existsSync9, unlinkSync as unlinkSync3, statSync as statSync2, openSync as openSync3, fstatSync, readSync, closeSync as closeSync3 } from "fs";
|
|
5193
|
+
import { execSync as execSync2 } from "child_process";
|
|
5194
|
+
import pc15 from "picocolors";
|
|
5195
|
+
var MAX_LOG_LINES = 500;
|
|
3564
5196
|
async function logCommand(args2) {
|
|
3565
5197
|
const target = args2[0];
|
|
3566
|
-
if (!target) {
|
|
3567
|
-
|
|
3568
|
-
|
|
5198
|
+
if (!target || target.startsWith("--")) {
|
|
5199
|
+
listAvailableApps();
|
|
5200
|
+
return;
|
|
3569
5201
|
}
|
|
3570
5202
|
const clearFlag = args2.includes("--clear");
|
|
5203
|
+
const jsonFlag = args2.includes("--json");
|
|
5204
|
+
const noRedact = args2.includes("--no-redact");
|
|
3571
5205
|
const linesIndex = args2.indexOf("--lines");
|
|
3572
|
-
const lineCount = linesIndex !== -1 ? parseInt(args2[linesIndex + 1], 10) : 30;
|
|
5206
|
+
const lineCount = linesIndex !== -1 ? Math.min(parseInt(args2[linesIndex + 1], 10), MAX_LOG_LINES) : 30;
|
|
3573
5207
|
const followFlag = args2.includes("-f") || args2.includes("--follow");
|
|
3574
5208
|
const levelFilter = args2.includes("--level") ? args2[args2.indexOf("--level") + 1]?.toLowerCase() : void 0;
|
|
5209
|
+
const sinceIndex = args2.indexOf("--since");
|
|
5210
|
+
const sinceMs = sinceIndex !== -1 ? parseDuration(args2[sinceIndex + 1]) : void 0;
|
|
5211
|
+
if (sinceIndex !== -1 && sinceMs === void 0) {
|
|
5212
|
+
console.error(renderError("Invalid --since", `Use a duration like 10m, 1h, 30s, 2d`));
|
|
5213
|
+
process.exit(1);
|
|
5214
|
+
}
|
|
3575
5215
|
if (levelFilter && !["error", "warn", "info", "debug"].includes(levelFilter)) {
|
|
3576
5216
|
console.error(renderError("Invalid level", `"${levelFilter}" is not a valid level. Use: error, warn, info, debug`));
|
|
3577
5217
|
process.exit(1);
|
|
3578
5218
|
}
|
|
5219
|
+
const redact = !noRedact;
|
|
3579
5220
|
let logFilePath = null;
|
|
3580
5221
|
let displayName = target;
|
|
3581
5222
|
const tracked = readTracked();
|
|
3582
5223
|
const trackedMatch = tracked.find((t) => t.name === target || parseInt(target, 10) === t.pid && !isNaN(parseInt(target, 10)));
|
|
3583
5224
|
if (trackedMatch) {
|
|
3584
5225
|
displayName = trackedMatch.name;
|
|
3585
|
-
logFilePath =
|
|
5226
|
+
logFilePath = logFilePathFor(trackedMatch.name);
|
|
3586
5227
|
} else {
|
|
3587
5228
|
const pid = parseInt(target, 10);
|
|
3588
5229
|
if (!isNaN(pid) && String(pid) === target) {
|
|
3589
5230
|
const procPath = `/proc/${pid}/fd/1`;
|
|
3590
|
-
if (
|
|
5231
|
+
if (existsSync9(procPath)) logFilePath = procPath;
|
|
3591
5232
|
}
|
|
3592
5233
|
}
|
|
3593
5234
|
if (clearFlag) {
|
|
3594
|
-
if (!logFilePath || !
|
|
5235
|
+
if (!logFilePath || !existsSync9(logFilePath)) {
|
|
3595
5236
|
console.error(`
|
|
3596
|
-
${
|
|
5237
|
+
${pc15.yellow("\u26A0")} ${pc15.dim("No log file found for")} ${pc15.bold(displayName)}
|
|
3597
5238
|
`);
|
|
3598
5239
|
process.exit(0);
|
|
3599
5240
|
}
|
|
3600
5241
|
try {
|
|
3601
|
-
|
|
5242
|
+
unlinkSync3(logFilePath);
|
|
3602
5243
|
console.error(`
|
|
3603
|
-
${
|
|
5244
|
+
${pc15.green("\u2713")} ${pc15.bold("Log cleared")} ${pc15.dim(`\u2014 ${logFilePath}`)}
|
|
3604
5245
|
`);
|
|
3605
5246
|
} catch (err) {
|
|
3606
5247
|
console.error(renderError("Failed to clear log", String(err)));
|
|
@@ -3608,18 +5249,75 @@ async function logCommand(args2) {
|
|
|
3608
5249
|
}
|
|
3609
5250
|
return;
|
|
3610
5251
|
}
|
|
5252
|
+
if (jsonFlag) {
|
|
5253
|
+
let logLines = [];
|
|
5254
|
+
if (logFilePath && existsSync9(logFilePath)) {
|
|
5255
|
+
logLines = readLogLines(logFilePath, {
|
|
5256
|
+
tail: Math.max(lineCount, sinceMs ? MAX_LOG_LINES : 0),
|
|
5257
|
+
sinceMs: sinceMs ?? void 0,
|
|
5258
|
+
redact,
|
|
5259
|
+
parseTimestamp: true
|
|
5260
|
+
});
|
|
5261
|
+
} else {
|
|
5262
|
+
const pid = trackedMatch?.pid ?? parseInt(target, 10);
|
|
5263
|
+
if (!isNaN(pid)) {
|
|
5264
|
+
try {
|
|
5265
|
+
const output = execSync2(`journalctl --no-pager -n ${Math.max(lineCount, 500)} _PID=${pid} 2>/dev/null || echo ""`, { encoding: "utf-8", timeout: 3e3 });
|
|
5266
|
+
logLines = output.trim().split("\n").filter(Boolean).map((l) => redact ? redactLine(stripAnsi(l)) : stripAnsi(l));
|
|
5267
|
+
} catch {
|
|
5268
|
+
logLines = ["(no logs available)"];
|
|
5269
|
+
}
|
|
5270
|
+
} else {
|
|
5271
|
+
logLines = ["(no logs available)"];
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
if (levelFilter) {
|
|
5275
|
+
const levelRegex = new RegExp(`\\b(${levelFilter.toUpperCase()})\\b`, "i");
|
|
5276
|
+
logLines = logLines.filter((line) => levelRegex.test(line));
|
|
5277
|
+
}
|
|
5278
|
+
const running = trackedMatch ? isTrackedRunning(trackedMatch) : false;
|
|
5279
|
+
const sliced = logLines.slice(-lineCount);
|
|
5280
|
+
const redactedHits = sliced.reduce((acc, l) => acc + (redactionCount(stripAnsi(l)) > 0 ? 1 : 0), 0);
|
|
5281
|
+
const payload = {
|
|
5282
|
+
app: displayName,
|
|
5283
|
+
running,
|
|
5284
|
+
pid: trackedMatch?.pid != null ? trackedMatch.pid : null,
|
|
5285
|
+
port: trackedMatch?.port != null ? trackedMatch.port : null,
|
|
5286
|
+
lines: sliced,
|
|
5287
|
+
count: sliced.length,
|
|
5288
|
+
redacted: redact,
|
|
5289
|
+
redactedLines: redactedHits,
|
|
5290
|
+
note: "Secrets are redacted by default; use --no-redact to disable."
|
|
5291
|
+
};
|
|
5292
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
5293
|
+
return;
|
|
5294
|
+
}
|
|
5295
|
+
if (trackedMatch) {
|
|
5296
|
+
const running = isTrackedRunning(trackedMatch);
|
|
5297
|
+
const statusStr = running ? pc15.green(`\u25CF running (PID ${trackedMatch.pid})`) : pc15.red("\u25CB stopped");
|
|
5298
|
+
const portStr = trackedMatch.port ? ` ${pc15.yellow(`:${trackedMatch.port}`)}` : "";
|
|
5299
|
+
console.error(`
|
|
5300
|
+
${symbols.fox} ${renderAppName(displayName)}${portStr} \u2014 ${statusStr}`);
|
|
5301
|
+
if (!running) {
|
|
5302
|
+
console.error(` ${pc15.dim("This app is not running. Showing last captured logs.")} ${pc15.cyan(`fennec spawn ${displayName}`)} ${pc15.dim("to restart.")}`);
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
3611
5305
|
const spinner = createSpinner(`Reading logs for ${displayName}...`);
|
|
3612
5306
|
try {
|
|
3613
5307
|
let logLines = [];
|
|
3614
|
-
if (logFilePath &&
|
|
3615
|
-
|
|
3616
|
-
|
|
5308
|
+
if (logFilePath && existsSync9(logFilePath)) {
|
|
5309
|
+
logLines = readLogLines(logFilePath, {
|
|
5310
|
+
tail: Math.max(lineCount, sinceMs ? MAX_LOG_LINES : 0),
|
|
5311
|
+
sinceMs: sinceMs ?? void 0,
|
|
5312
|
+
redact,
|
|
5313
|
+
parseTimestamp: true
|
|
5314
|
+
});
|
|
3617
5315
|
} else {
|
|
3618
5316
|
const pid = trackedMatch?.pid ?? parseInt(target, 10);
|
|
3619
5317
|
if (!isNaN(pid)) {
|
|
3620
5318
|
try {
|
|
3621
|
-
const output = execSync2(`journalctl --no-pager -n ${lineCount} _PID=${pid} 2>/dev/null || echo ""`, { encoding: "utf-8", timeout: 3e3 });
|
|
3622
|
-
logLines = output.trim().split("\n").filter(Boolean);
|
|
5319
|
+
const output = execSync2(`journalctl --no-pager -n ${Math.max(lineCount, 500)} _PID=${pid} 2>/dev/null || echo ""`, { encoding: "utf-8", timeout: 3e3 });
|
|
5320
|
+
logLines = output.trim().split("\n").filter(Boolean).map((l) => redact ? redactLine(stripAnsi(l)) : stripAnsi(l));
|
|
3623
5321
|
} catch {
|
|
3624
5322
|
logLines = ["(no logs available)"];
|
|
3625
5323
|
}
|
|
@@ -3634,48 +5332,22 @@ async function logCommand(args2) {
|
|
|
3634
5332
|
spinner.stop();
|
|
3635
5333
|
process.stdout.write("\r\x1B[K");
|
|
3636
5334
|
console.error(`
|
|
3637
|
-
${symbols.fox} ${
|
|
5335
|
+
${symbols.fox} ${pc15.bold("Logs")} ${renderAppName(displayName)} ${pc15.dim(`(last ${logLines.length} line${logLines.length !== 1 ? "s" : ""})${redact ? pc15.dim(" \xB7 secrets redacted") : ""})`)}`);
|
|
3638
5336
|
const sliced = logLines.slice(-lineCount);
|
|
3639
5337
|
for (const line of sliced) {
|
|
3640
5338
|
const display = line.length > 300 ? line.slice(0, 300) + "\u2026" : line;
|
|
3641
|
-
const prefix = followFlag ? `${timestamp()} ` : "";
|
|
3642
5339
|
if (line.toLowerCase().includes("error") || line.toLowerCase().includes("fail") || line.toLowerCase().includes("fatal")) {
|
|
3643
|
-
console.error(` ${
|
|
5340
|
+
console.error(` ${pc15.red(display)}`);
|
|
3644
5341
|
} else if (line.toLowerCase().includes("warn")) {
|
|
3645
|
-
console.error(` ${
|
|
5342
|
+
console.error(` ${pc15.yellow(display)}`);
|
|
3646
5343
|
} else if (line.toLowerCase().includes("info") || line.includes("[")) {
|
|
3647
|
-
console.error(` ${
|
|
5344
|
+
console.error(` ${pc15.cyan(display)}`);
|
|
3648
5345
|
} else {
|
|
3649
|
-
console.error(` ${
|
|
5346
|
+
console.error(` ${display}`);
|
|
3650
5347
|
}
|
|
3651
5348
|
}
|
|
3652
5349
|
if (followFlag && logFilePath) {
|
|
3653
|
-
|
|
3654
|
-
if (levelFilter) {
|
|
3655
|
-
const tail = spawn4("tail", ["-n", "0", "-f", logFilePath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
3656
|
-
const grep = spawn4("grep", ["--line-buffered", "-i", levelFilter], { stdio: ["pipe", "inherit", "inherit"] });
|
|
3657
|
-
if (tail.stdout) tail.stdout.pipe(grep.stdin);
|
|
3658
|
-
await new Promise((resolve12) => {
|
|
3659
|
-
tail.on("exit", () => resolve12());
|
|
3660
|
-
process.once("SIGINT", () => {
|
|
3661
|
-
tail.kill("SIGTERM");
|
|
3662
|
-
grep.kill("SIGTERM");
|
|
3663
|
-
resolve12();
|
|
3664
|
-
});
|
|
3665
|
-
});
|
|
3666
|
-
} else {
|
|
3667
|
-
console.error(`
|
|
3668
|
-
${pc10.dim("Following... (Ctrl+C to stop)")}
|
|
3669
|
-
`);
|
|
3670
|
-
const tail = spawn4("tail", tailArgs.concat([logFilePath]), { stdio: "inherit" });
|
|
3671
|
-
await new Promise((resolve12) => {
|
|
3672
|
-
tail.on("exit", () => resolve12());
|
|
3673
|
-
process.once("SIGINT", () => {
|
|
3674
|
-
tail.kill("SIGTERM");
|
|
3675
|
-
resolve12();
|
|
3676
|
-
});
|
|
3677
|
-
});
|
|
3678
|
-
}
|
|
5350
|
+
await followLog(logFilePath, { redact, levelFilter, json: jsonFlag });
|
|
3679
5351
|
}
|
|
3680
5352
|
console.error();
|
|
3681
5353
|
} catch (error) {
|
|
@@ -3683,10 +5355,97 @@ async function logCommand(args2) {
|
|
|
3683
5355
|
console.error(renderError("Log read failed", String(error)));
|
|
3684
5356
|
}
|
|
3685
5357
|
}
|
|
5358
|
+
async function followLog(logFilePath, opts) {
|
|
5359
|
+
let lastSize = 0;
|
|
5360
|
+
try {
|
|
5361
|
+
lastSize = statSync2(logFilePath).size;
|
|
5362
|
+
} catch {
|
|
5363
|
+
}
|
|
5364
|
+
console.error(`
|
|
5365
|
+
${pc15.dim("Following... (Ctrl+C to stop)")}
|
|
5366
|
+
`);
|
|
5367
|
+
const levelRe = opts.levelFilter ? new RegExp(`\\b(${opts.levelFilter.toUpperCase()})\\b`, "i") : null;
|
|
5368
|
+
const emit = (raw) => {
|
|
5369
|
+
const line = opts.redact ? redactLine(raw) : raw;
|
|
5370
|
+
if (levelRe && !levelRe.test(line)) return;
|
|
5371
|
+
if (opts.json) {
|
|
5372
|
+
try {
|
|
5373
|
+
process.stdout.write(JSON.stringify(JSON.parse(line)) + "\n");
|
|
5374
|
+
return;
|
|
5375
|
+
} catch {
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
const display = line.length > 300 ? line.slice(0, 300) + "\u2026" : line;
|
|
5379
|
+
if (line.toLowerCase().includes("error") || line.toLowerCase().includes("fail") || line.toLowerCase().includes("fatal")) {
|
|
5380
|
+
console.error(` ${pc15.red(display)}`);
|
|
5381
|
+
} else if (line.toLowerCase().includes("warn")) {
|
|
5382
|
+
console.error(` ${pc15.yellow(display)}`);
|
|
5383
|
+
} else if (line.toLowerCase().includes("info") || line.includes("[")) {
|
|
5384
|
+
console.error(` ${pc15.cyan(display)}`);
|
|
5385
|
+
} else {
|
|
5386
|
+
console.error(` ${display}`);
|
|
5387
|
+
}
|
|
5388
|
+
};
|
|
5389
|
+
const timer = setInterval(() => {
|
|
5390
|
+
let fd;
|
|
5391
|
+
try {
|
|
5392
|
+
fd = openSync3(logFilePath, "r");
|
|
5393
|
+
} catch {
|
|
5394
|
+
return;
|
|
5395
|
+
}
|
|
5396
|
+
try {
|
|
5397
|
+
const size = fstatSync(fd).size;
|
|
5398
|
+
if (size < lastSize) lastSize = 0;
|
|
5399
|
+
if (size > lastSize) {
|
|
5400
|
+
const buf = Buffer.alloc(size - lastSize);
|
|
5401
|
+
readSync(fd, buf, 0, buf.length, lastSize);
|
|
5402
|
+
lastSize = size;
|
|
5403
|
+
for (const l of buf.toString("utf8").split("\n")) {
|
|
5404
|
+
if (l.length) emit(l);
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
} finally {
|
|
5408
|
+
closeSync3(fd);
|
|
5409
|
+
}
|
|
5410
|
+
}, 400);
|
|
5411
|
+
await new Promise((resolve13) => {
|
|
5412
|
+
const stop = () => {
|
|
5413
|
+
clearInterval(timer);
|
|
5414
|
+
resolve13();
|
|
5415
|
+
};
|
|
5416
|
+
process.once("SIGINT", stop);
|
|
5417
|
+
process.once("SIGTERM", stop);
|
|
5418
|
+
});
|
|
5419
|
+
}
|
|
5420
|
+
function listAvailableApps() {
|
|
5421
|
+
const tracked = readTracked();
|
|
5422
|
+
if (tracked.length === 0) {
|
|
5423
|
+
console.error(`
|
|
5424
|
+
${symbols.fox} ${pc15.bold("No tracked apps to show logs for.")}`);
|
|
5425
|
+
console.error(` ${pc15.dim("Start one with:")} ${pc15.cyan("fennec start <command> --name <name>")}
|
|
5426
|
+
`);
|
|
5427
|
+
return;
|
|
5428
|
+
}
|
|
5429
|
+
console.error(`
|
|
5430
|
+
${symbols.fox} ${pc15.bold("Logs")} ${pc15.dim("\u2014 pick an app:")}
|
|
5431
|
+
`);
|
|
5432
|
+
for (const t of tracked) {
|
|
5433
|
+
const running = isTrackedRunning(t);
|
|
5434
|
+
const dot = running ? pc15.green("\u25CF") : pc15.red("\u25CB");
|
|
5435
|
+
const state = running ? pc15.green(`running (PID ${t.pid})`) : pc15.red("stopped");
|
|
5436
|
+
const portStr = t.port ? ` ${pc15.yellow(`:${t.port}`)}` : "";
|
|
5437
|
+
console.error(` ${dot} ${pc15.bold(t.name)}${portStr} ${pc15.dim("\u2014")} ${state}`);
|
|
5438
|
+
console.error(` ${pc15.cyan(`fennec log ${t.name}`)} ${pc15.dim("\xB7")} ${pc15.cyan(`fennec log ${t.name} -f`)}`);
|
|
5439
|
+
}
|
|
5440
|
+
console.error(`
|
|
5441
|
+
${pc15.dim("Flags:")} ${pc15.cyan("-f")} ${pc15.dim("follow")} ${pc15.dim("\xB7")} ${pc15.cyan("--lines N")} ${pc15.dim("\xB7")} ${pc15.cyan("--since 10m|1h")} ${pc15.dim("\xB7")} ${pc15.cyan("--level error|warn|info|debug")} ${pc15.dim("\xB7")} ${pc15.cyan("--json")} ${pc15.dim("(AI)")} ${pc15.dim("\xB7")} ${pc15.cyan("--no-redact")} ${pc15.dim("\xB7")} ${pc15.cyan("--clear")}`);
|
|
5442
|
+
console.error(` ${pc15.dim("AI mode:")} ${pc15.cyan("fennec log <app> --json --since 10m")} ${pc15.dim("\u2014 bounded, redacted, machine-readable. Secrets are redacted by default.")}
|
|
5443
|
+
`);
|
|
5444
|
+
}
|
|
3686
5445
|
|
|
3687
5446
|
// src/commands/attach.ts
|
|
3688
5447
|
import { PortDetector as PortDetector3 } from "@plumpslabs/fennec-core";
|
|
3689
|
-
import
|
|
5448
|
+
import pc16 from "picocolors";
|
|
3690
5449
|
async function attachCommand(args2) {
|
|
3691
5450
|
const port = parseInt(args2[0], 10);
|
|
3692
5451
|
if (isNaN(port)) {
|
|
@@ -3704,7 +5463,7 @@ async function attachCommand(args2) {
|
|
|
3704
5463
|
spinner.succeed(`Attached to :${port}`);
|
|
3705
5464
|
console.error(` ${renderKV("Name", renderAppName(name))}`);
|
|
3706
5465
|
console.error(` ${renderKV("PID", String(info.pid))}`);
|
|
3707
|
-
console.error(` ${renderKV("Command", info.command ||
|
|
5466
|
+
console.error(` ${renderKV("Command", info.command || pc16.dim("unknown"))}`);
|
|
3708
5467
|
} else {
|
|
3709
5468
|
spinner.fail(`No process found on port ${port}`);
|
|
3710
5469
|
process.exit(1);
|
|
@@ -3718,35 +5477,35 @@ async function attachCommand(args2) {
|
|
|
3718
5477
|
|
|
3719
5478
|
// src/commands/sessions.ts
|
|
3720
5479
|
import { SessionStore } from "@plumpslabs/fennec-core";
|
|
3721
|
-
import
|
|
5480
|
+
import pc17 from "picocolors";
|
|
3722
5481
|
async function sessionsCommand() {
|
|
3723
5482
|
const store = new SessionStore("./.fennec/sessions");
|
|
3724
5483
|
const sessions = store.list();
|
|
3725
5484
|
if (sessions.length === 0) {
|
|
3726
5485
|
console.error(`
|
|
3727
|
-
${
|
|
5486
|
+
${pc17.dim("No saved sessions found.")}
|
|
3728
5487
|
`);
|
|
3729
5488
|
return;
|
|
3730
5489
|
}
|
|
3731
5490
|
const columns = [
|
|
3732
|
-
{ key: "name", label: "Name", format: (v) =>
|
|
5491
|
+
{ key: "name", label: "Name", format: (v) => pc17.bold(String(v)) },
|
|
3733
5492
|
{ key: "origin", label: "Origin" },
|
|
3734
|
-
{ key: "savedAt", label: "Saved", format: (v) =>
|
|
5493
|
+
{ key: "savedAt", label: "Saved", format: (v) => pc17.dim(String(v)) }
|
|
3735
5494
|
];
|
|
3736
5495
|
const rows = sessions.map((s) => ({ name: s.name, origin: s.origin, savedAt: new Date(s.savedAt).toLocaleString() }));
|
|
3737
5496
|
console.error(`
|
|
3738
|
-
${symbols.fox} ${
|
|
5497
|
+
${symbols.fox} ${pc17.bold("Saved Sessions")}
|
|
3739
5498
|
`);
|
|
3740
5499
|
console.error(renderTable(columns, rows));
|
|
3741
|
-
console.error(` ${
|
|
5500
|
+
console.error(` ${pc17.dim(`${sessions.length} session(s)`)}
|
|
3742
5501
|
`);
|
|
3743
5502
|
}
|
|
3744
5503
|
|
|
3745
5504
|
// src/commands/setup.ts
|
|
3746
|
-
import
|
|
5505
|
+
import pc18 from "picocolors";
|
|
3747
5506
|
async function setupCommand() {
|
|
3748
5507
|
console.error(`
|
|
3749
|
-
${symbols.fox} ${
|
|
5508
|
+
${symbols.fox} ${pc18.bold("Fennec Setup")}
|
|
3750
5509
|
`);
|
|
3751
5510
|
const mcpClient = await selectPrompt("Which MCP client are you using?", [
|
|
3752
5511
|
{ value: "claude", label: "Claude Desktop", description: "Anthropic's AI desktop app" },
|
|
@@ -3755,12 +5514,12 @@ async function setupCommand() {
|
|
|
3755
5514
|
{ value: "other", label: "Other MCP client", description: "Any MCP-compatible client" }
|
|
3756
5515
|
]);
|
|
3757
5516
|
if (!mcpClient) {
|
|
3758
|
-
console.error(` ${
|
|
5517
|
+
console.error(` ${pc18.dim("Setup cancelled.")}
|
|
3759
5518
|
`);
|
|
3760
5519
|
return;
|
|
3761
5520
|
}
|
|
3762
5521
|
console.error(`
|
|
3763
|
-
${
|
|
5522
|
+
${pc18.green("\u2713")} Selected: ${pc18.bold(mcpClient)}
|
|
3764
5523
|
`);
|
|
3765
5524
|
const configSnippet = `{
|
|
3766
5525
|
"mcpServers": {
|
|
@@ -3770,24 +5529,24 @@ async function setupCommand() {
|
|
|
3770
5529
|
}
|
|
3771
5530
|
}
|
|
3772
5531
|
}`;
|
|
3773
|
-
console.error(` ${
|
|
5532
|
+
console.error(` ${pc18.bold("Add this to your MCP client config:")}
|
|
3774
5533
|
`);
|
|
3775
|
-
console.error(` ${
|
|
5534
|
+
console.error(` ${pc18.dim("```")}`);
|
|
3776
5535
|
console.error(configSnippet.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
3777
|
-
console.error(` ${
|
|
5536
|
+
console.error(` ${pc18.dim("```")}
|
|
3778
5537
|
`);
|
|
3779
|
-
console.error(` ${renderSuccess("Setup complete!")} ${
|
|
5538
|
+
console.error(` ${renderSuccess("Setup complete!")} ${pc18.dim("Run")} ${renderCommand("fennec start")} ${pc18.dim("to begin.")}
|
|
3780
5539
|
`);
|
|
3781
5540
|
}
|
|
3782
5541
|
|
|
3783
5542
|
// src/commands/management.ts
|
|
3784
|
-
import { existsSync as
|
|
3785
|
-
import { resolve as
|
|
5543
|
+
import { existsSync as existsSync10, writeFileSync as writeFileSync4 } from "fs";
|
|
5544
|
+
import { resolve as resolve8 } from "path";
|
|
3786
5545
|
import { execSync as execSync3 } from "child_process";
|
|
3787
|
-
import
|
|
5546
|
+
import pc19 from "picocolors";
|
|
3788
5547
|
async function installBrowsersCommand() {
|
|
3789
5548
|
console.error(`
|
|
3790
|
-
${
|
|
5549
|
+
${pc19.bold("Installing Browser Engines")}
|
|
3791
5550
|
`);
|
|
3792
5551
|
const spinner = createSpinner("Installing Chromium...");
|
|
3793
5552
|
try {
|
|
@@ -3795,21 +5554,21 @@ async function installBrowsersCommand() {
|
|
|
3795
5554
|
spinner.succeed("Chromium installed successfully");
|
|
3796
5555
|
} catch {
|
|
3797
5556
|
spinner.fail("Failed to install Chromium");
|
|
3798
|
-
console.error(` ${
|
|
5557
|
+
console.error(` ${pc19.yellow("\u2192")} Try running: ${renderCommand("npx playwright install chromium")}`);
|
|
3799
5558
|
}
|
|
3800
5559
|
console.error(`
|
|
3801
|
-
${
|
|
5560
|
+
${pc19.green("\u2713")} Browser installation complete.
|
|
3802
5561
|
`);
|
|
3803
5562
|
}
|
|
3804
5563
|
async function initCommand() {
|
|
3805
5564
|
console.error(`
|
|
3806
|
-
${
|
|
5565
|
+
${pc19.bold("Initialize Fennec Configuration")}
|
|
3807
5566
|
`);
|
|
3808
|
-
const configFile =
|
|
3809
|
-
if (
|
|
3810
|
-
const overwrite = await confirmPrompt(`${
|
|
5567
|
+
const configFile = resolve8("./fennec.config.yaml");
|
|
5568
|
+
if (existsSync10(configFile)) {
|
|
5569
|
+
const overwrite = await confirmPrompt(`${pc19.yellow("fennec.config.yaml")} already exists. Overwrite?`, false);
|
|
3811
5570
|
if (!overwrite) {
|
|
3812
|
-
console.error(` ${
|
|
5571
|
+
console.error(` ${pc19.dim("Cancelled.")}
|
|
3813
5572
|
`);
|
|
3814
5573
|
return;
|
|
3815
5574
|
}
|
|
@@ -3888,22 +5647,22 @@ logging:
|
|
|
3888
5647
|
format: pretty
|
|
3889
5648
|
file: null
|
|
3890
5649
|
`;
|
|
3891
|
-
|
|
3892
|
-
spinner.succeed(`Configuration written to ${
|
|
5650
|
+
writeFileSync4(configFile, config, "utf-8");
|
|
5651
|
+
spinner.succeed(`Configuration written to ${pc19.bold(configFile)}`);
|
|
3893
5652
|
console.error(`
|
|
3894
|
-
${
|
|
5653
|
+
${pc19.dim("Edit the file to customize Fennec behavior.")}
|
|
3895
5654
|
`);
|
|
3896
5655
|
}
|
|
3897
5656
|
|
|
3898
5657
|
// src/commands/health.ts
|
|
3899
|
-
import { existsSync as
|
|
5658
|
+
import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
3900
5659
|
import { execSync as execSync4 } from "child_process";
|
|
3901
|
-
import { resolve as
|
|
5660
|
+
import { resolve as resolve9 } from "path";
|
|
3902
5661
|
import { homedir as homedir6 } from "os";
|
|
3903
|
-
import
|
|
5662
|
+
import pc20 from "picocolors";
|
|
3904
5663
|
async function healthCommand() {
|
|
3905
5664
|
printBanner();
|
|
3906
|
-
console.error(` ${
|
|
5665
|
+
console.error(` ${pc20.bold("Fennec Health Check")}
|
|
3907
5666
|
`);
|
|
3908
5667
|
let adbStatus = "unknown";
|
|
3909
5668
|
try {
|
|
@@ -3913,20 +5672,20 @@ async function healthCommand() {
|
|
|
3913
5672
|
stdio: ["ignore", "pipe", "pipe"]
|
|
3914
5673
|
});
|
|
3915
5674
|
const version = result.split("\n")[0]?.trim() ?? "installed";
|
|
3916
|
-
adbStatus =
|
|
5675
|
+
adbStatus = pc20.green(version);
|
|
3917
5676
|
} catch {
|
|
3918
|
-
adbStatus =
|
|
5677
|
+
adbStatus = pc20.dim("not found") + " (optional)";
|
|
3919
5678
|
}
|
|
3920
5679
|
const tracked = readTracked();
|
|
3921
5680
|
const runningCount = tracked.filter((t) => isProcessRunning(t.pid)).length;
|
|
3922
|
-
const logDir =
|
|
5681
|
+
const logDir = resolve9(homedir6(), ".fennec", "logs");
|
|
3923
5682
|
let logSize = "0 B";
|
|
3924
5683
|
try {
|
|
3925
|
-
if (
|
|
5684
|
+
if (existsSync11(logDir)) {
|
|
3926
5685
|
const files = readdirSync2(logDir);
|
|
3927
5686
|
let totalBytes = 0;
|
|
3928
5687
|
for (const f of files) {
|
|
3929
|
-
const fStat =
|
|
5688
|
+
const fStat = statSync3(resolve9(logDir, f));
|
|
3930
5689
|
totalBytes += fStat.size;
|
|
3931
5690
|
}
|
|
3932
5691
|
logSize = totalBytes > 1024 * 1024 ? `${(totalBytes / (1024 * 1024)).toFixed(1)} MB` : `${(totalBytes / 1024).toFixed(1)} KB`;
|
|
@@ -3939,7 +5698,7 @@ async function healthCommand() {
|
|
|
3939
5698
|
memoryInfo = `${(mem.rss / (1024 * 1024)).toFixed(0)} MB RSS / ${(mem.heapUsed / (1024 * 1024)).toFixed(0)} MB heap`;
|
|
3940
5699
|
} catch {
|
|
3941
5700
|
}
|
|
3942
|
-
console.error(` ${symbols.fox} ${
|
|
5701
|
+
console.error(` ${symbols.fox} ${pc20.bold("System")}
|
|
3943
5702
|
`);
|
|
3944
5703
|
console.error(` ${renderKV("Node.js", process.version)}`);
|
|
3945
5704
|
console.error(` ${renderKV("Platform", `${process.platform} ${process.arch}`)}`);
|
|
@@ -3947,7 +5706,7 @@ async function healthCommand() {
|
|
|
3947
5706
|
console.error(` ${renderKV("ADB", adbStatus)}`);
|
|
3948
5707
|
console.error(` ${renderKV("PID", String(process.pid))}`);
|
|
3949
5708
|
console.error();
|
|
3950
|
-
console.error(` ${symbols.fox} ${
|
|
5709
|
+
console.error(` ${symbols.fox} ${pc20.bold("Processes")}
|
|
3951
5710
|
`);
|
|
3952
5711
|
console.error(` ${renderKV("Tracked", String(tracked.length))}`);
|
|
3953
5712
|
console.error(` ${renderKV("Running", String(runningCount))}`);
|
|
@@ -3955,55 +5714,55 @@ async function healthCommand() {
|
|
|
3955
5714
|
console.error();
|
|
3956
5715
|
const allHealthy = runningCount === tracked.length || tracked.length === 0;
|
|
3957
5716
|
if (allHealthy) {
|
|
3958
|
-
console.error(` ${
|
|
5717
|
+
console.error(` ${pc20.green("\u2713")} ${pc20.bold("All systems healthy")}
|
|
3959
5718
|
`);
|
|
3960
5719
|
} else {
|
|
3961
5720
|
const stopped = tracked.length - runningCount;
|
|
3962
|
-
console.error(` ${
|
|
5721
|
+
console.error(` ${pc20.yellow("\u26A0")} ${pc20.bold(`${stopped} process(es) stopped`)} ${pc20.dim("fennec ps")}
|
|
3963
5722
|
`);
|
|
3964
5723
|
}
|
|
3965
5724
|
}
|
|
3966
5725
|
|
|
3967
5726
|
// src/commands/cleanup.ts
|
|
3968
|
-
import
|
|
5727
|
+
import pc21 from "picocolors";
|
|
3969
5728
|
async function cleanupCommand() {
|
|
3970
5729
|
const tracked = readTracked();
|
|
3971
5730
|
if (tracked.length === 0) {
|
|
3972
5731
|
console.error(`
|
|
3973
|
-
${
|
|
5732
|
+
${pc21.dim("No tracked processes to clean up.")}
|
|
3974
5733
|
`);
|
|
3975
5734
|
return;
|
|
3976
5735
|
}
|
|
3977
5736
|
const toRemove = tracked.filter((t) => !isProcessRunning(t.pid) && !t.command);
|
|
3978
5737
|
if (toRemove.length === 0) {
|
|
3979
5738
|
console.error(`
|
|
3980
|
-
${
|
|
5739
|
+
${pc21.green("\u2713")} ${pc21.bold("All clean")} ${pc21.dim("\u2014 no dead entries without saved commands.")}
|
|
3981
5740
|
`);
|
|
3982
5741
|
return;
|
|
3983
5742
|
}
|
|
3984
5743
|
console.error(`
|
|
3985
|
-
${
|
|
5744
|
+
${pc21.yellow("\u26A0")} ${pc21.bold(`Found ${toRemove.length} dead entr${toRemove.length > 1 ? "ies" : "y"} without saved commands`)}
|
|
3986
5745
|
`);
|
|
3987
5746
|
for (const entry of toRemove) {
|
|
3988
|
-
console.error(` ${
|
|
5747
|
+
console.error(` ${pc21.dim("\xB7")} ${pc21.bold(entry.name)} ${pc21.dim(`(PID ${entry.pid})`)}`);
|
|
3989
5748
|
}
|
|
3990
5749
|
console.error();
|
|
3991
5750
|
const confirmed = await confirmPrompt(`Remove ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}?`, false);
|
|
3992
5751
|
if (!confirmed) {
|
|
3993
|
-
console.error(` ${
|
|
5752
|
+
console.error(` ${pc21.dim("Cancelled")}
|
|
3994
5753
|
`);
|
|
3995
5754
|
return;
|
|
3996
5755
|
}
|
|
3997
5756
|
const remaining = tracked.filter((t) => !toRemove.includes(t));
|
|
3998
5757
|
saveTracked(remaining);
|
|
3999
5758
|
console.error(`
|
|
4000
|
-
${
|
|
5759
|
+
${pc21.green("\u2713")} ${pc21.bold(`Removed ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}`)} ${pc21.dim(`(${remaining.length} remaining)`)}
|
|
4001
5760
|
`);
|
|
4002
5761
|
}
|
|
4003
5762
|
|
|
4004
5763
|
// src/commands/info.ts
|
|
4005
|
-
import
|
|
4006
|
-
import { resolve as
|
|
5764
|
+
import pc22 from "picocolors";
|
|
5765
|
+
import { resolve as resolve10 } from "path";
|
|
4007
5766
|
import { homedir as homedir7 } from "os";
|
|
4008
5767
|
async function infoCommand(args2) {
|
|
4009
5768
|
const name = args2[0];
|
|
@@ -4018,33 +5777,33 @@ async function infoCommand(args2) {
|
|
|
4018
5777
|
process.exit(1);
|
|
4019
5778
|
}
|
|
4020
5779
|
const running = isProcessRunning(match.pid);
|
|
4021
|
-
const statusIcon = running ?
|
|
4022
|
-
const statusText = running ?
|
|
5780
|
+
const statusIcon = running ? pc22.green("\u25CF") : pc22.red("\u25CB");
|
|
5781
|
+
const statusText = running ? pc22.green("running") : pc22.red("stopped");
|
|
4023
5782
|
const uptime = running ? formatUptime(Math.floor((Date.now() - new Date(match.startedAt).getTime()) / 1e3)) : "-";
|
|
4024
|
-
const logPath =
|
|
5783
|
+
const logPath = resolve10(homedir7(), ".fennec", "logs", `${match.name}.log`);
|
|
4025
5784
|
console.error(`
|
|
4026
|
-
${symbols.fox} ${
|
|
5785
|
+
${symbols.fox} ${pc22.bold(match.name)} ${pc22.dim("\u2014 Process Info")}
|
|
4027
5786
|
`);
|
|
4028
|
-
console.error(` ${renderKVColor("Name", match.name,
|
|
5787
|
+
console.error(` ${renderKVColor("Name", match.name, pc22.bold)}`);
|
|
4029
5788
|
console.error(` ${renderKVColor("Status", `${statusIcon} ${statusText}`)}`);
|
|
4030
5789
|
console.error(` ${renderKVColor("PID", String(match.pid))}`);
|
|
4031
5790
|
console.error(` ${renderKVColor("Port", match.port ? String(match.port) : "-")}`);
|
|
4032
|
-
console.error(` ${renderKVColor("Command", match.command ||
|
|
5791
|
+
console.error(` ${renderKVColor("Command", match.command || pc22.dim("(none)"))}`);
|
|
4033
5792
|
if (match.cwd) console.error(` ${renderKVColor("CWD", match.cwd)}`);
|
|
4034
5793
|
console.error(` ${renderKVColor("Started", new Date(match.startedAt).toLocaleString())}`);
|
|
4035
5794
|
console.error(` ${renderKVColor("Uptime", uptime)}`);
|
|
4036
5795
|
console.error(` ${renderKVColor("Log Path", logPath)}`);
|
|
4037
5796
|
if (!running && match.command) {
|
|
4038
5797
|
console.error(`
|
|
4039
|
-
${
|
|
5798
|
+
${pc22.dim("Re-spawn with:")} ${pc22.cyan(`fennec spawn ${match.name}`)}`);
|
|
4040
5799
|
}
|
|
4041
5800
|
console.error();
|
|
4042
5801
|
}
|
|
4043
5802
|
|
|
4044
5803
|
// src/commands/rename.ts
|
|
4045
|
-
import
|
|
4046
|
-
import { existsSync as
|
|
4047
|
-
import { resolve as
|
|
5804
|
+
import pc23 from "picocolors";
|
|
5805
|
+
import { existsSync as existsSync12, renameSync as renameSync2 } from "fs";
|
|
5806
|
+
import { resolve as resolve11 } from "path";
|
|
4048
5807
|
import { homedir as homedir8 } from "os";
|
|
4049
5808
|
async function renameCommand(args2) {
|
|
4050
5809
|
const [oldName, newName] = args2;
|
|
@@ -4054,7 +5813,7 @@ async function renameCommand(args2) {
|
|
|
4054
5813
|
}
|
|
4055
5814
|
if (oldName === newName) {
|
|
4056
5815
|
console.error(`
|
|
4057
|
-
${
|
|
5816
|
+
${pc23.yellow("\u26A0")} ${pc23.dim("Old and new names are the same.")}
|
|
4058
5817
|
`);
|
|
4059
5818
|
process.exit(0);
|
|
4060
5819
|
}
|
|
@@ -4069,25 +5828,25 @@ async function renameCommand(args2) {
|
|
|
4069
5828
|
process.exit(1);
|
|
4070
5829
|
}
|
|
4071
5830
|
console.error(`
|
|
4072
|
-
${symbols.fox} ${
|
|
5831
|
+
${symbols.fox} ${pc23.bold("Rename Process")}
|
|
4073
5832
|
`);
|
|
4074
5833
|
console.error(` ${renderKVColor("From", oldName)}`);
|
|
4075
5834
|
console.error(` ${renderKVColor("To", newName)}`);
|
|
4076
5835
|
if (isProcessRunning(match.pid)) {
|
|
4077
|
-
console.error(` ${
|
|
5836
|
+
console.error(` ${pc23.yellow("\u26A0")} ${pc23.dim("Process is still running \u2014 log entries will go to the old file until stopped.")}`);
|
|
4078
5837
|
}
|
|
4079
5838
|
console.error();
|
|
4080
|
-
const confirmed = await confirmPrompt(`Rename ${
|
|
5839
|
+
const confirmed = await confirmPrompt(`Rename ${pc23.bold(oldName)} ${pc23.dim("\u2192")} ${pc23.bold(newName)}?`, true);
|
|
4081
5840
|
if (!confirmed) {
|
|
4082
|
-
console.error(` ${
|
|
5841
|
+
console.error(` ${pc23.dim("Cancelled")}
|
|
4083
5842
|
`);
|
|
4084
5843
|
return;
|
|
4085
5844
|
}
|
|
4086
5845
|
const spinner = createSpinner(`Renaming ${oldName} \u2192 ${newName}...`);
|
|
4087
|
-
const logDir =
|
|
4088
|
-
const oldLog =
|
|
4089
|
-
const newLog =
|
|
4090
|
-
if (
|
|
5846
|
+
const logDir = resolve11(homedir8(), ".fennec", "logs");
|
|
5847
|
+
const oldLog = resolve11(logDir, `${oldName}.log`);
|
|
5848
|
+
const newLog = resolve11(logDir, `${newName}.log`);
|
|
5849
|
+
if (existsSync12(oldLog) && !existsSync12(newLog)) {
|
|
4091
5850
|
try {
|
|
4092
5851
|
renameSync2(oldLog, newLog);
|
|
4093
5852
|
} catch {
|
|
@@ -4095,14 +5854,14 @@ async function renameCommand(args2) {
|
|
|
4095
5854
|
}
|
|
4096
5855
|
match.name = newName;
|
|
4097
5856
|
saveTracked(tracked);
|
|
4098
|
-
spinner.succeed(`${
|
|
5857
|
+
spinner.succeed(`${pc23.bold(oldName)} ${pc23.dim("\u2192")} ${pc23.bold(newName)}`);
|
|
4099
5858
|
console.error();
|
|
4100
5859
|
}
|
|
4101
5860
|
|
|
4102
5861
|
// src/commands/export-import.ts
|
|
4103
|
-
import
|
|
4104
|
-
import { existsSync as
|
|
4105
|
-
import { resolve as
|
|
5862
|
+
import pc24 from "picocolors";
|
|
5863
|
+
import { existsSync as existsSync13, writeFileSync as writeFileSync5, readFileSync as readFileSync9 } from "fs";
|
|
5864
|
+
import { resolve as resolve12 } from "path";
|
|
4106
5865
|
async function exportCommand(args2) {
|
|
4107
5866
|
const tracked = readTracked();
|
|
4108
5867
|
const fileIndex = args2.indexOf("--file");
|
|
@@ -4113,16 +5872,16 @@ async function exportCommand(args2) {
|
|
|
4113
5872
|
}
|
|
4114
5873
|
if (tracked.length === 0) {
|
|
4115
5874
|
console.error(`
|
|
4116
|
-
${
|
|
5875
|
+
${pc24.dim("No tracked processes to export.")}
|
|
4117
5876
|
`);
|
|
4118
5877
|
return;
|
|
4119
5878
|
}
|
|
4120
5879
|
const json = JSON.stringify(tracked, null, 2);
|
|
4121
5880
|
if (filePath) {
|
|
4122
5881
|
try {
|
|
4123
|
-
|
|
5882
|
+
writeFileSync5(resolve12(filePath), json, "utf-8");
|
|
4124
5883
|
console.error(`
|
|
4125
|
-
${
|
|
5884
|
+
${pc24.green("\u2713")} ${pc24.bold(`Exported ${tracked.length} process(es)`)} ${pc24.dim(`\u2192 ${filePath}`)}
|
|
4126
5885
|
`);
|
|
4127
5886
|
} catch (err) {
|
|
4128
5887
|
console.error(renderError("Export failed", String(err)));
|
|
@@ -4138,14 +5897,14 @@ async function importCommand(args2) {
|
|
|
4138
5897
|
console.error(renderError("Missing file", "Usage: fennec import <file>"));
|
|
4139
5898
|
process.exit(1);
|
|
4140
5899
|
}
|
|
4141
|
-
const resolvedPath =
|
|
4142
|
-
if (!
|
|
5900
|
+
const resolvedPath = resolve12(filePath);
|
|
5901
|
+
if (!existsSync13(resolvedPath)) {
|
|
4143
5902
|
console.error(renderError("File not found", `"${filePath}" does not exist.`));
|
|
4144
5903
|
process.exit(1);
|
|
4145
5904
|
}
|
|
4146
5905
|
let imported;
|
|
4147
5906
|
try {
|
|
4148
|
-
imported = JSON.parse(
|
|
5907
|
+
imported = JSON.parse(readFileSync9(resolvedPath, "utf-8"));
|
|
4149
5908
|
if (!Array.isArray(imported) || imported.length === 0) {
|
|
4150
5909
|
console.error(renderError("Invalid file", "File must contain a non-empty JSON array of process objects."));
|
|
4151
5910
|
process.exit(1);
|
|
@@ -4156,20 +5915,20 @@ async function importCommand(args2) {
|
|
|
4156
5915
|
}
|
|
4157
5916
|
const existing = readTracked();
|
|
4158
5917
|
console.error(`
|
|
4159
|
-
${symbols.fox} ${
|
|
5918
|
+
${symbols.fox} ${pc24.bold("Import Processes")}
|
|
4160
5919
|
`);
|
|
4161
5920
|
console.error(` ${renderKVColor("File", resolvedPath)}`);
|
|
4162
5921
|
console.error(` ${renderKVColor("Importing", `${imported.length} process(es)`)}`);
|
|
4163
5922
|
console.error(` ${renderKVColor("Existing", `${existing.length} process(es)`)}`);
|
|
4164
5923
|
console.error(`
|
|
4165
|
-
${
|
|
5924
|
+
${pc24.bold("Processes to import:")}`);
|
|
4166
5925
|
for (const p of imported) {
|
|
4167
|
-
console.error(` ${
|
|
5926
|
+
console.error(` ${pc24.green("+")} ${pc24.bold(p.name)} ${pc24.dim(`(${p.command})`)}`);
|
|
4168
5927
|
}
|
|
4169
5928
|
console.error();
|
|
4170
5929
|
const confirmed = await confirmPrompt("Merge into tracked.json? Existing processes with the same name will be overwritten.", true);
|
|
4171
5930
|
if (!confirmed) {
|
|
4172
|
-
console.error(` ${
|
|
5931
|
+
console.error(` ${pc24.dim("Cancelled")}
|
|
4173
5932
|
`);
|
|
4174
5933
|
return;
|
|
4175
5934
|
}
|
|
@@ -4183,13 +5942,18 @@ async function importCommand(args2) {
|
|
|
4183
5942
|
}
|
|
4184
5943
|
}
|
|
4185
5944
|
saveTracked(merged);
|
|
4186
|
-
console.error(` ${
|
|
5945
|
+
console.error(` ${pc24.green("\u2713")} ${pc24.bold(`Imported ${imported.length} process(es)`)} ${pc24.dim(`(${merged.length} total)`)}
|
|
4187
5946
|
`);
|
|
4188
5947
|
}
|
|
4189
5948
|
|
|
4190
5949
|
// src/index.ts
|
|
4191
5950
|
var [, , command, ...args] = process.argv;
|
|
4192
5951
|
async function main() {
|
|
5952
|
+
if (command && command !== "help" && command !== "--help" && command !== "-h" && (args.includes("--help") || args.includes("-h"))) {
|
|
5953
|
+
printBanner();
|
|
5954
|
+
showCommandHelp(command);
|
|
5955
|
+
return;
|
|
5956
|
+
}
|
|
4193
5957
|
if (!command || command === "start") {
|
|
4194
5958
|
if (args.length === 0 || args[0]?.startsWith("--")) {
|
|
4195
5959
|
await startServer(args);
|
|
@@ -4204,7 +5968,7 @@ async function main() {
|
|
|
4204
5968
|
} else if (command === "status") {
|
|
4205
5969
|
printBanner();
|
|
4206
5970
|
await statusCommand(args);
|
|
4207
|
-
} else if (command === "log") {
|
|
5971
|
+
} else if (command === "log" || command === "logs") {
|
|
4208
5972
|
await logCommand(args);
|
|
4209
5973
|
} else if (command === "spawn") {
|
|
4210
5974
|
await spawnCommand(args);
|
|
@@ -4214,6 +5978,21 @@ async function main() {
|
|
|
4214
5978
|
await killCommand(args);
|
|
4215
5979
|
} else if (command === "restart") {
|
|
4216
5980
|
await restartCommand(args);
|
|
5981
|
+
} else if (command === "adopt") {
|
|
5982
|
+
adoptCommand(args);
|
|
5983
|
+
} else if (command === "supervisor") {
|
|
5984
|
+
printBanner();
|
|
5985
|
+
await supervisorCommand(args);
|
|
5986
|
+
} else if (command === "__supervisor") {
|
|
5987
|
+
await runSupervisor();
|
|
5988
|
+
} else if (command === "persist") {
|
|
5989
|
+
printBanner();
|
|
5990
|
+
await persistCommand(args);
|
|
5991
|
+
} else if (command === "inspect") {
|
|
5992
|
+
await inspectCommand(args);
|
|
5993
|
+
} else if (command === "dev") {
|
|
5994
|
+
printBanner();
|
|
5995
|
+
await devCommand(args);
|
|
4217
5996
|
} else if (command === "info") {
|
|
4218
5997
|
printBanner();
|
|
4219
5998
|
await infoCommand(args);
|
|
@@ -4252,7 +6031,11 @@ async function main() {
|
|
|
4252
6031
|
printBanner();
|
|
4253
6032
|
} else if (command === "help" || command === "--help" || command === "-h") {
|
|
4254
6033
|
printBanner();
|
|
4255
|
-
|
|
6034
|
+
if (args[0] && !args[0].startsWith("-")) {
|
|
6035
|
+
showCommandHelp(args[0]);
|
|
6036
|
+
} else {
|
|
6037
|
+
showHelp();
|
|
6038
|
+
}
|
|
4256
6039
|
} else {
|
|
4257
6040
|
console.error(renderError(`Unknown command: ${command}`, "Run 'fennec help' for usage information"));
|
|
4258
6041
|
process.exit(1);
|