@plumpslabs/fennec-cli 1.14.2 → 1.14.4

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/dist/index.js CHANGED
@@ -1922,7 +1922,7 @@ function renderTable(columns, rows, options) {
1922
1922
  "top-mid": "\u252C",
1923
1923
  "top-left": "\u250C",
1924
1924
  "top-right": "\u2510",
1925
- "bottom": "\u2500",
1925
+ bottom: "\u2500",
1926
1926
  "bottom-mid": "\u2534",
1927
1927
  "bottom-left": "\u2514",
1928
1928
  "bottom-right": "\u2518",
@@ -1961,6 +1961,14 @@ function renderKV(key, value, options) {
1961
1961
  function renderKVColor(key, value, color = pc.bold) {
1962
1962
  return ` ${pc.dim(key + ":")} ${color(value)}`;
1963
1963
  }
1964
+ function renderSection(title, content) {
1965
+ const line = pc.dim("\u2500".repeat(40));
1966
+ return `
1967
+ ${pc.bold(title)}
1968
+ ${line}
1969
+ ${content}
1970
+ `;
1971
+ }
1964
1972
  function createSpinner(text) {
1965
1973
  const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1966
1974
  let i = 0;
@@ -2013,10 +2021,10 @@ async function selectPrompt(message, options) {
2013
2021
  console.log(` ${pc.dim("0) Cancel")}
2014
2022
  `);
2015
2023
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2016
- const answer = await new Promise((resolve13) => {
2024
+ const answer = await new Promise((resolve12) => {
2017
2025
  rl.question(` ${pc.bold("Enter number")} ${pc.dim("(0-" + options.length + ")")}: `, (ans) => {
2018
2026
  rl.close();
2019
- resolve13(ans.trim());
2027
+ resolve12(ans.trim());
2020
2028
  });
2021
2029
  });
2022
2030
  const num = parseInt(answer, 10);
@@ -2031,11 +2039,11 @@ async function selectPrompt(message, options) {
2031
2039
  async function confirmPrompt(message, defaultValue = false) {
2032
2040
  const hint = defaultValue ? pc.bold("Y") + pc.dim("/n") : pc.dim("y/") + pc.bold("N");
2033
2041
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2034
- const answer = await new Promise((resolve13) => {
2042
+ const answer = await new Promise((resolve12) => {
2035
2043
  rl.question(`
2036
2044
  ${message} ${pc.dim("(" + hint + ")")}: `, (ans) => {
2037
2045
  rl.close();
2038
- resolve13(ans.trim().toLowerCase());
2046
+ resolve12(ans.trim().toLowerCase());
2039
2047
  });
2040
2048
  });
2041
2049
  if (!answer) return defaultValue;
@@ -2091,7 +2099,7 @@ function hexColor(color) {
2091
2099
  }
2092
2100
  var fennecOrange2 = hexColor("#FF6432");
2093
2101
  var fennecGold = hexColor("#FFB347");
2094
- var VERSION = "1.14.2";
2102
+ var VERSION = "1.14.3";
2095
2103
  var cachedBanner = null;
2096
2104
  var cachedCompact = null;
2097
2105
  function generateBanner() {
@@ -2158,11 +2166,15 @@ var COMMANDS = {
2158
2166
  ["--name <name>", "App name used for tracking (recommended)"],
2159
2167
  ["--port <port>", "Port the app listens on \u2014 Fennec waits until it accepts connections"],
2160
2168
  ["--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)"]
2169
+ [
2170
+ "--restart",
2171
+ "Auto-restart if it crashes or its port stops listening (detached supervisor, survives terminal close)"
2172
+ ],
2173
+ ["--group <group>", "Logical group/namespace for bulk ops (kill/spawn/stop --group <group>)"]
2162
2174
  ],
2163
2175
  examples: [
2164
2176
  'start "npm run dev" --name web --port 3000',
2165
- "start node server.js --name api --cwd ./backend --restart"
2177
+ "start node server.js --name api --cwd ./backend --restart --group backend"
2166
2178
  ]
2167
2179
  },
2168
2180
  run: {
@@ -2175,18 +2187,20 @@ var COMMANDS = {
2175
2187
  ["--name <name>", "App name used for tracking (recommended)"],
2176
2188
  ["--port <port>", "Port the app listens on"],
2177
2189
  ["--cwd <dir>", "Working directory"],
2178
- ["--restart", "Auto-restart if it crashes or its port stops listening"]
2190
+ ["--restart", "Auto-restart if it crashes or its port stops listening"],
2191
+ ["--group <group>", "Logical group for bulk ops"]
2179
2192
  ],
2180
- examples: ["run npm start --name web --port 8080"]
2193
+ examples: ["run npm start --name web --port 8080 --group frontend"]
2181
2194
  },
2182
2195
  ps: {
2183
2196
  name: "ps",
2184
2197
  usage: "ps [options]",
2185
2198
  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).",
2199
+ description: "Shows tracked apps and whether they are genuinely running (PID + command identity are verified to avoid false positives from PID reuse). Includes a cross-platform MEM column (resident RSS) so you can spot leaks from long-lived apps.",
2187
2200
  options: [
2188
2201
  ["--system, -a, --all", "Show all system processes instead of tracked apps"],
2189
2202
  ["--name <name>", "Filter system processes by name"],
2203
+ ["--group <g>, -g <g>", "Show only tracked apps in group <g>"],
2190
2204
  ["--sort <field>", "Sort by cpu | mem | pid | name (default: name)"],
2191
2205
  ["--json", "Output tracked apps as JSON"],
2192
2206
  ["-w, --watch", "Watch mode \u2014 refresh every 3s (with --system)"]
@@ -2219,37 +2233,51 @@ var COMMANDS = {
2219
2233
  },
2220
2234
  spawn: {
2221
2235
  name: "spawn",
2222
- usage: "spawn [name] [--all]",
2236
+ usage: "spawn [name] [name...] [--all] [--group <g>]",
2223
2237
  summary: "Re-spawn a stopped tracked app from its saved config",
2224
2238
  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"]
2239
+ options: [
2240
+ ["--all, -a", "Re-spawn all stopped apps that have a saved command"],
2241
+ ["--group <g>, -g <g>", "Re-spawn only stopped apps in group <g>"]
2242
+ ],
2243
+ examples: ["spawn", "spawn web", "spawn be-crm fe-crm", "spawn --all", "spawn --group backend"]
2227
2244
  },
2228
2245
  stop: {
2229
2246
  name: "stop",
2230
- usage: "stop <name|--all>",
2247
+ usage: "stop <name|--all> [name...] [--group <g>]",
2231
2248
  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"]
2249
+ description: "Sends SIGTERM but keeps the entry in tracked.json so it can be revived later with `fennec spawn`. Stops the ENTIRE process tree so no orphaned children are left. Accepts multiple names at once (e.g. `stop be-crm fe-crm`).",
2250
+ options: [
2251
+ ["--all, -a", "Stop all running tracked apps"],
2252
+ ["--group <g>, -g <g>", "Stop only running apps in group <g>"]
2253
+ ],
2254
+ examples: ["stop web", "stop be-crm fe-crm -y", "stop --all", "stop --group backend"]
2235
2255
  },
2236
2256
  restart: {
2237
2257
  name: "restart",
2238
- usage: "restart <name|pid>",
2258
+ usage: "restart <name|pid> [name...] [--group <g>]",
2239
2259
  summary: "Stop and re-spawn a tracked app from its saved config",
2240
- examples: ["restart web"]
2260
+ options: [["--group <g>, -g <g>", "Restart all apps in group <g>"]],
2261
+ examples: ["restart web", "restart be-crm fe-crm -y", "restart --group backend"]
2241
2262
  },
2242
2263
  kill: {
2243
2264
  name: "kill",
2244
- usage: "kill <pid|name|all>",
2245
- summary: "Kill a Fennec-tracked process (by PID, tracked name, or all tracked)",
2246
- description: "Permanently removes a tracked app (unlike `stop`, which keeps it). By NAME it only matches Fennec-tracked apps \u2014 use an explicit PID to kill a system process. Prompts before killing.",
2265
+ usage: "kill <pid|name|all> [name...] [--group <g>]",
2266
+ summary: "Kill a process and remove it from the registry",
2267
+ description: "Permanently removes a tracked app (unlike `stop`, which keeps it). By NAME it only matches Fennec-tracked apps \u2014 use an explicit PID to kill a system process. Prompts before killing. Kills the ENTIRE process tree (e.g. npm \u2192 vite \u2192 esbuild) so no orphaned children are left behind. Accepts multiple names at once (e.g. `kill be-crm fe-crm`).",
2247
2268
  options: [
2248
2269
  ["--signal <sig>", "Signal to send (default: SIGTERM). e.g. SIGKILL, SIGINT"],
2249
2270
  ["--all, -a", "Kill ALL tracked apps (asks for confirmation)"],
2271
+ ["--group <g>, -g <g>", "Kill only apps in group <g> (other groups untouched)"],
2250
2272
  ["-y, --yes", "Skip the confirmation prompt"]
2251
2273
  ],
2252
- examples: ["kill web", "kill 12345 --signal SIGKILL", "kill all -y"]
2274
+ examples: [
2275
+ "kill web",
2276
+ "kill 12345 --signal SIGKILL",
2277
+ "kill be-crm fe-crm -y",
2278
+ "kill all -y",
2279
+ "kill --group backend -y"
2280
+ ]
2253
2281
  },
2254
2282
  supervisor: {
2255
2283
  name: "supervisor",
@@ -2317,6 +2345,18 @@ var COMMANDS = {
2317
2345
  summary: "Rename a tracked app",
2318
2346
  examples: ["rename web frontend"]
2319
2347
  },
2348
+ group: {
2349
+ name: "group",
2350
+ usage: "group [name] [group]",
2351
+ summary: "Assign a logical group to tracked apps (for scoped bulk ops)",
2352
+ description: "Groups let bulk commands (kill/spawn/stop/ps) target a subset instead of everything. Assign a group to an existing tracked app, clear it with --unset, or run with no args to list every app's group. New apps can also be tagged at start time with `fennec start <cmd> --name <n> --group <g>`. Then `fennec kill --group <g>` / `fennec spawn --group <g>` / `fennec stop --group <g>` / `fennec ps --group <g>` only touch that group.",
2353
+ options: [
2354
+ ["<name> <group>", "Assign <group> to the tracked app <name>"],
2355
+ ["<name> --unset", "Remove the group from <name>"],
2356
+ ["(no args)", "List all tracked apps with their group"]
2357
+ ],
2358
+ examples: ["group web backend", "group web --unset", "group", "kill --group backend"]
2359
+ },
2320
2360
  attach: {
2321
2361
  name: "attach",
2322
2362
  usage: "attach <port> --name <name>",
@@ -2431,7 +2471,27 @@ function findCommandDoc(cmd) {
2431
2471
  }
2432
2472
  var GROUPS = [
2433
2473
  { title: "Server", keys: ["start-server"] },
2434
- { title: "Apps & Processes", keys: ["start", "run", "ps", "status", "log", "spawn", "stop", "restart", "kill", "supervisor", "persist", "dev", "inspect", "info", "rename"] },
2474
+ {
2475
+ title: "Apps & Processes",
2476
+ keys: [
2477
+ "start",
2478
+ "run",
2479
+ "ps",
2480
+ "status",
2481
+ "log",
2482
+ "spawn",
2483
+ "stop",
2484
+ "restart",
2485
+ "kill",
2486
+ "group",
2487
+ "supervisor",
2488
+ "persist",
2489
+ "dev",
2490
+ "inspect",
2491
+ "info",
2492
+ "rename"
2493
+ ]
2494
+ },
2435
2495
  { title: "Observation", keys: ["attach", "attach-pid", "attach-port", "pipe", "watch"] },
2436
2496
  { title: "Data", keys: ["export", "import", "cleanup"] },
2437
2497
  { title: "Configuration", keys: ["init", "setup", "install-browsers", "sessions"] },
@@ -2445,7 +2505,9 @@ function showHelp() {
2445
2505
  const lines = [];
2446
2506
  lines.push("");
2447
2507
  lines.push(` ${pc3.bold("Usage:")} fennec ${pc3.dim("<command>")} ${pc3.dim("[options]")}`);
2448
- lines.push(` ${pc3.dim("Run")} ${pc3.cyan("fennec <command> --help")} ${pc3.dim("for details on any command.")}`);
2508
+ lines.push(
2509
+ ` ${pc3.dim("Run")} ${pc3.cyan("fennec <command> --help")} ${pc3.dim("for details on any command.")}`
2510
+ );
2449
2511
  for (const group of GROUPS) {
2450
2512
  lines.push("");
2451
2513
  lines.push(` ${sep}`);
@@ -2470,8 +2532,10 @@ function showCommandHelp(cmd) {
2470
2532
  if (!doc) {
2471
2533
  console.error(`
2472
2534
  ${pc3.yellow("\u26A0")} Unknown command: ${pc3.bold(cmd)}`);
2473
- console.error(` ${pc3.dim("Run")} ${pc3.cyan("fennec help")} ${pc3.dim("to see all commands.")}
2474
- `);
2535
+ console.error(
2536
+ ` ${pc3.dim("Run")} ${pc3.cyan("fennec help")} ${pc3.dim("to see all commands.")}
2537
+ `
2538
+ );
2475
2539
  return;
2476
2540
  }
2477
2541
  const lines = [];
@@ -2580,7 +2644,9 @@ async function attachPortCommand(args2) {
2580
2644
  const detector = new PortDetector2();
2581
2645
  const info = detector.detectByPort(port);
2582
2646
  if (info) {
2583
- console.log(`Found process on port ${port}: PID ${info.pid}${info.command ? ` (${info.command})` : ""}`);
2647
+ console.log(
2648
+ `Found process on port ${port}: PID ${info.pid}${info.command ? ` (${info.command})` : ""}`
2649
+ );
2584
2650
  } else {
2585
2651
  console.error(`No process found listening on port ${port}`);
2586
2652
  process.exit(1);
@@ -2619,7 +2685,18 @@ import { FennecServer } from "@plumpslabs/fennec-core";
2619
2685
  import pc7 from "picocolors";
2620
2686
 
2621
2687
  // src/commands/tracker.ts
2622
- import { existsSync as existsSync3, writeFileSync, readFileSync as readFileSync2, mkdirSync, renameSync, statSync, rmSync, openSync, closeSync, writeSync } from "fs";
2688
+ import {
2689
+ existsSync as existsSync3,
2690
+ writeFileSync,
2691
+ readFileSync as readFileSync2,
2692
+ mkdirSync,
2693
+ renameSync,
2694
+ statSync,
2695
+ rmSync,
2696
+ openSync,
2697
+ closeSync,
2698
+ writeSync
2699
+ } from "fs";
2623
2700
  import { spawn } from "child_process";
2624
2701
  import { resolve as resolve2, dirname, basename } from "path";
2625
2702
  import { homedir } from "os";
@@ -2681,7 +2758,10 @@ function readSingleProcProcess(pid, currentUid, totalMemKb) {
2681
2758
  if (uptimeSeconds > 0) {
2682
2759
  const elapsedJiffies = uptimeSeconds * hertz;
2683
2760
  const processJiffies = totalJiffies;
2684
- cpuPercent = Math.min(100, processJiffies / Math.max(1, elapsedJiffies) * 100 * cpus().length);
2761
+ cpuPercent = Math.min(
2762
+ 100,
2763
+ processJiffies / Math.max(1, elapsedJiffies) * 100 * cpus().length
2764
+ );
2685
2765
  }
2686
2766
  startedAt = formatStartTime(starttime, uptimeSeconds, hertz);
2687
2767
  }
@@ -2872,12 +2952,32 @@ function getSystemProcesses(filter) {
2872
2952
  }
2873
2953
  return processes;
2874
2954
  }
2875
- function killProcess(pid, signal = "SIGTERM") {
2955
+ function killTree(pid, signal = "SIGTERM") {
2956
+ if (process.platform === "win32") {
2957
+ try {
2958
+ execSync(`taskkill /pid ${pid} /T /F`, { stdio: "ignore", timeout: 5e3 });
2959
+ return true;
2960
+ } catch {
2961
+ try {
2962
+ process.kill(pid, signal);
2963
+ return true;
2964
+ } catch {
2965
+ return false;
2966
+ }
2967
+ }
2968
+ }
2876
2969
  try {
2877
- process.kill(pid, signal);
2970
+ process.kill(-pid, signal);
2878
2971
  return true;
2879
- } catch {
2880
- return false;
2972
+ } catch (e) {
2973
+ const code = e.code;
2974
+ if (code === "ESRCH") return false;
2975
+ try {
2976
+ process.kill(pid, signal);
2977
+ return true;
2978
+ } catch {
2979
+ return false;
2980
+ }
2881
2981
  }
2882
2982
  }
2883
2983
  function isProcessRunning(pid) {
@@ -2888,15 +2988,54 @@ function isProcessRunning(pid) {
2888
2988
  return false;
2889
2989
  }
2890
2990
  }
2991
+ function getProcessMemRss(pid) {
2992
+ if (process.platform === "win32") {
2993
+ try {
2994
+ const out = execSync(`tasklist /fi "PID eq ${pid}" /fo csv /nh 2>nul`, {
2995
+ encoding: "utf-8",
2996
+ timeout: 3e3
2997
+ });
2998
+ for (const raw of out.split("\n")) {
2999
+ if (!raw.trim()) continue;
3000
+ const parts = raw.split('","').map((p) => p.replace(/^"|"$/g, ""));
3001
+ if (parseInt(parts[1] ?? "", 10) !== pid) continue;
3002
+ const memKb = parseInt((parts[4] ?? "").replace(/[^\d]/g, ""), 10);
3003
+ return isNaN(memKb) ? null : memKb;
3004
+ }
3005
+ return null;
3006
+ } catch {
3007
+ return null;
3008
+ }
3009
+ }
3010
+ if (process.platform === "darwin" || process.platform === "freebsd" || process.platform === "openbsd" || process.platform === "netbsd") {
3011
+ try {
3012
+ const out = execSync(`ps -o rss= -p ${pid} 2>/dev/null`, {
3013
+ encoding: "utf-8",
3014
+ timeout: 3e3
3015
+ }).trim();
3016
+ const kb = parseInt(out, 10);
3017
+ return isNaN(kb) ? null : kb;
3018
+ } catch {
3019
+ return null;
3020
+ }
3021
+ }
3022
+ try {
3023
+ const status = readFileSync(`/proc/${pid}/status`, "utf-8");
3024
+ const m = status.match(/VmRSS:\s+(\d+)/);
3025
+ return m ? parseInt(m[1], 10) : null;
3026
+ } catch {
3027
+ return null;
3028
+ }
3029
+ }
2891
3030
  function checkPort(port, host = "127.0.0.1", timeout = 1e3) {
2892
- const tryHost = (h) => new Promise((resolve13) => {
3031
+ const tryHost = (h) => new Promise((resolve12) => {
2893
3032
  const socket = new net.Socket();
2894
3033
  let settled = false;
2895
3034
  const finish = (result) => {
2896
3035
  if (settled) return;
2897
3036
  settled = true;
2898
3037
  socket.destroy();
2899
- resolve13(result);
3038
+ resolve12(result);
2900
3039
  };
2901
3040
  socket.setTimeout(timeout);
2902
3041
  socket.once("connect", () => finish(true));
@@ -2968,7 +3107,10 @@ function findPidOnPortLinux(port) {
2968
3107
  }
2969
3108
  function findPidOnPortMac(port) {
2970
3109
  try {
2971
- const out = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n 2>/dev/null`, { encoding: "utf-8", timeout: 5e3 });
3110
+ const out = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n 2>/dev/null`, {
3111
+ encoding: "utf-8",
3112
+ timeout: 5e3
3113
+ });
2972
3114
  for (const line of out.split("\n")) {
2973
3115
  if (!line.includes("LISTEN")) continue;
2974
3116
  const parts = line.trim().split(/\s+/);
@@ -3023,7 +3165,10 @@ function getProcessCmdlineMac(pid) {
3023
3165
  }
3024
3166
  function getProcessCmdlineWindows(pid) {
3025
3167
  try {
3026
- const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /value 2>nul`, { encoding: "utf-8", timeout: 3e3 });
3168
+ const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /value 2>nul`, {
3169
+ encoding: "utf-8",
3170
+ timeout: 3e3
3171
+ });
3027
3172
  const line = out.split("\n").find((l) => l.includes("="));
3028
3173
  const value = line?.split("=")[1]?.trim();
3029
3174
  return value ? value : null;
@@ -3041,7 +3186,10 @@ function getProcessCwd(pid) {
3041
3186
  }
3042
3187
  if (process.platform === "darwin") {
3043
3188
  try {
3044
- const out = execSync(`lsof -p ${pid} -a -d cwd -F n 2>/dev/null`, { encoding: "utf-8", timeout: 3e3 });
3189
+ const out = execSync(`lsof -p ${pid} -a -d cwd -F n 2>/dev/null`, {
3190
+ encoding: "utf-8",
3191
+ timeout: 3e3
3192
+ });
3045
3193
  const line = out.split("\n").find((l) => l.startsWith("n"));
3046
3194
  return line ? line.slice(1) : null;
3047
3195
  } catch {
@@ -3089,6 +3237,44 @@ function formatProcessState(state) {
3089
3237
  }
3090
3238
 
3091
3239
  // src/commands/tracker.ts
3240
+ function extractFlagValue(args2, long, short) {
3241
+ for (let i = 0; i < args2.length; i++) {
3242
+ const a = args2[i];
3243
+ if (a === long || short && a === short) return args2[i + 1];
3244
+ if (a.startsWith(`${long}=`)) return a.slice(long.length + 1);
3245
+ if (short && a.startsWith(`${short}=`)) return a.slice(short.length + 1);
3246
+ }
3247
+ return void 0;
3248
+ }
3249
+ function getGroups() {
3250
+ const tracked = readTracked();
3251
+ const groups = /* @__PURE__ */ new Set();
3252
+ for (const t of tracked) if (t.group) groups.add(t.group);
3253
+ return [...groups].sort();
3254
+ }
3255
+ function resolveTargets(args2) {
3256
+ const groupFlag = extractFlagValue(args2, "--group", "-g");
3257
+ const all = args2.includes("--all") || args2.includes("-a");
3258
+ const positionals = args2.filter((a) => !a.startsWith("-") && !a.includes("="));
3259
+ const groups = getGroups();
3260
+ if (groupFlag) return { kind: "group", group: groupFlag };
3261
+ if (positionals.length === 1 && positionals[0] === "all") return { kind: "all" };
3262
+ if (all) return { kind: "all" };
3263
+ if (positionals.length === 1 && groups.includes(positionals[0])) {
3264
+ return { kind: "group", group: positionals[0] };
3265
+ }
3266
+ if (positionals.length === 1) return { kind: "single", value: positionals[0] };
3267
+ if (positionals.length > 1) return { kind: "names", values: positionals };
3268
+ return { kind: "none" };
3269
+ }
3270
+ function setGroup(name, group) {
3271
+ const tracked = readTracked();
3272
+ const idx = tracked.findIndex((t) => t.name === name);
3273
+ if (idx === -1) return false;
3274
+ tracked[idx] = { ...tracked[idx], group };
3275
+ saveTracked(tracked);
3276
+ return true;
3277
+ }
3092
3278
  var RESTART_CAUSE = "fennec:restart";
3093
3279
  function getFennecDir() {
3094
3280
  return process.env.FENNEC_DATA_DIR ? resolve2(process.env.FENNEC_DATA_DIR) : resolve2(homedir(), ".fennec");
@@ -3117,7 +3303,10 @@ function saveTracked(processes) {
3117
3303
  mkdirSync(dirname(path), { recursive: true });
3118
3304
  writeFileSync(path, JSON.stringify(processes, null, 2), "utf-8");
3119
3305
  } catch (err) {
3120
- console.error("[fennec] Failed to save tracked processes:", err instanceof Error ? err.message : String(err));
3306
+ console.error(
3307
+ "[fennec] Failed to save tracked processes:",
3308
+ err instanceof Error ? err.message : String(err)
3309
+ );
3121
3310
  }
3122
3311
  }
3123
3312
  function addTracked(proc) {
@@ -3233,7 +3422,10 @@ function rotateLogFile(filePath, maxSize = DEFAULT_MAX_LOG_SIZE, maxFiles = DEFA
3233
3422
  }
3234
3423
  return true;
3235
3424
  } catch (err) {
3236
- console.error("[fennec] Log rotation failed:", err instanceof Error ? err.message : String(err));
3425
+ console.error(
3426
+ "[fennec] Log rotation failed:",
3427
+ err instanceof Error ? err.message : String(err)
3428
+ );
3237
3429
  return false;
3238
3430
  }
3239
3431
  }
@@ -3253,7 +3445,7 @@ function respawnTracked(proc, cause) {
3253
3445
  if (cause) annotateRestart(logFilePath, cause);
3254
3446
  if (isProcessRunning(proc.pid)) {
3255
3447
  try {
3256
- process.kill(proc.pid, "SIGTERM");
3448
+ killTree(proc.pid, "SIGTERM");
3257
3449
  } catch {
3258
3450
  }
3259
3451
  }
@@ -3298,15 +3490,11 @@ function spawnDaemon(opts) {
3298
3490
  return child;
3299
3491
  }
3300
3492
  function spawnDaemonJsonl(opts, env) {
3301
- const relay = spawn(
3302
- process.execPath,
3303
- ["-e", JSONL_RELAY],
3304
- {
3305
- env: { ...process.env, FENNEC_RELAY_LOG: opts.logFilePath },
3306
- stdio: ["pipe", "ignore", "ignore"],
3307
- detached: true
3308
- }
3309
- );
3493
+ const relay = spawn(process.execPath, ["-e", JSONL_RELAY], {
3494
+ env: { ...process.env, FENNEC_RELAY_LOG: opts.logFilePath },
3495
+ stdio: ["pipe", "ignore", "ignore"],
3496
+ detached: true
3497
+ });
3310
3498
  relay.unref();
3311
3499
  const child = spawn(opts.cmdParts[0], opts.cmdParts.slice(1), {
3312
3500
  cwd: opts.cwd,
@@ -3362,7 +3550,16 @@ ${RESTART_CAUSE} cause=${cause} at=${(/* @__PURE__ */ new Date()).toISOString()}
3362
3550
 
3363
3551
  // src/commands/supervisor.ts
3364
3552
  import pc4 from "picocolors";
3365
- import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2, openSync as openSync2, closeSync as closeSync2 } from "fs";
3553
+ import {
3554
+ existsSync as existsSync4,
3555
+ readFileSync as readFileSync3,
3556
+ writeFileSync as writeFileSync2,
3557
+ unlinkSync,
3558
+ mkdirSync as mkdirSync2,
3559
+ openSync as openSync2,
3560
+ closeSync as closeSync2,
3561
+ statSync as statSync2
3562
+ } from "fs";
3366
3563
  import { spawn as spawn2 } from "child_process";
3367
3564
  import { dirname as dirname2 } from "path";
3368
3565
  var POLL_INTERVAL_MS = 3e3;
@@ -3384,6 +3581,38 @@ function ensureSupervisorRunning() {
3384
3581
  return startSupervisorDaemon();
3385
3582
  }
3386
3583
  function startSupervisorDaemon() {
3584
+ const already = getSupervisorPid();
3585
+ if (already) return already;
3586
+ const lockPath = `${getSupervisorPidPath()}.lock`;
3587
+ const SUPERVISOR_LOCK_STALE_MS = 6e4;
3588
+ const acquireLock = () => {
3589
+ try {
3590
+ const lfd = openSync2(lockPath, "wx");
3591
+ closeSync2(lfd);
3592
+ return true;
3593
+ } catch {
3594
+ return false;
3595
+ }
3596
+ };
3597
+ if (!acquireLock()) {
3598
+ const live = getSupervisorPid();
3599
+ if (live) return live;
3600
+ let stale = false;
3601
+ try {
3602
+ stale = Date.now() - statSync2(lockPath).mtimeMs > SUPERVISOR_LOCK_STALE_MS;
3603
+ } catch {
3604
+ stale = false;
3605
+ }
3606
+ if (stale) {
3607
+ try {
3608
+ unlinkSync(lockPath);
3609
+ } catch {
3610
+ }
3611
+ if (!acquireLock()) return getSupervisorPid() ?? 0;
3612
+ } else {
3613
+ return 0;
3614
+ }
3615
+ }
3387
3616
  const logFilePath = logFilePathFor("supervisor");
3388
3617
  mkdirSync2(dirname2(logFilePath), { recursive: true });
3389
3618
  const fd = openSync2(logFilePath, "a");
@@ -3414,24 +3643,30 @@ async function supervisorCommand(args2) {
3414
3643
  case "status":
3415
3644
  return supervisorStatus();
3416
3645
  default:
3417
- console.error(renderError("Unknown sub-command", `"${sub}" \u2014 use: start | stop | status | restart`));
3646
+ console.error(
3647
+ renderError("Unknown sub-command", `"${sub}" \u2014 use: start | stop | status | restart`)
3648
+ );
3418
3649
  process.exit(1);
3419
3650
  }
3420
3651
  }
3421
3652
  function supervisorStart() {
3422
3653
  const existing = getSupervisorPid();
3423
3654
  if (existing) {
3424
- console.error(`
3655
+ console.error(
3656
+ `
3425
3657
  ${pc4.yellow("\u26A0")} Supervisor already running ${pc4.dim(`(PID ${existing})`)}
3426
- `);
3658
+ `
3659
+ );
3427
3660
  return;
3428
3661
  }
3429
3662
  const pid = startSupervisorDaemon();
3430
3663
  console.error(`
3431
3664
  ${pc4.green("\u2713")} ${pc4.bold("Supervisor started")} ${pc4.dim(`(PID ${pid})`)}`);
3432
3665
  console.error(` ${renderKV("Logs", pc4.cyan("fennec log supervisor"))}`);
3433
- console.error(` ${pc4.dim("It will auto-restart apps started with")} ${pc4.cyan("--restart")} ${pc4.dim("even if you close this terminal.")}
3434
- `);
3666
+ console.error(
3667
+ ` ${pc4.dim("It will auto-restart apps started with")} ${pc4.cyan("--restart")} ${pc4.dim("even if you close this terminal.")}
3668
+ `
3669
+ );
3435
3670
  }
3436
3671
  function supervisorStop() {
3437
3672
  const pid = getSupervisorPid();
@@ -3442,10 +3677,12 @@ function supervisorStop() {
3442
3677
  return;
3443
3678
  }
3444
3679
  try {
3445
- process.kill(pid, "SIGTERM");
3446
- console.error(`
3680
+ killTree(pid, "SIGTERM");
3681
+ console.error(
3682
+ `
3447
3683
  ${pc4.green("\u2713")} ${pc4.bold("Supervisor stopped")} ${pc4.dim(`(PID ${pid})`)}
3448
- `);
3684
+ `
3685
+ );
3449
3686
  } catch (err) {
3450
3687
  console.error(renderError("Failed to stop supervisor", String(err)));
3451
3688
  }
@@ -3463,12 +3700,18 @@ function supervisorStatus() {
3463
3700
  if (pid) {
3464
3701
  console.error(` ${pc4.green("\u25CF")} running ${pc4.dim(`(PID ${pid})`)}`);
3465
3702
  } else {
3466
- console.error(` ${pc4.red("\u25CB")} not running ${pc4.dim("\u2014 start with")} ${pc4.cyan("fennec supervisor start")}`);
3703
+ console.error(
3704
+ ` ${pc4.red("\u25CB")} not running ${pc4.dim("\u2014 start with")} ${pc4.cyan("fennec supervisor start")}`
3705
+ );
3467
3706
  }
3468
- console.error(`
3469
- ${pc4.bold("Managed apps")} ${pc4.dim(`(${managed.length} with auto-restart)`)}`);
3707
+ console.error(
3708
+ `
3709
+ ${pc4.bold("Managed apps")} ${pc4.dim(`(${managed.length} with auto-restart)`)}`
3710
+ );
3470
3711
  if (managed.length === 0) {
3471
- console.error(` ${pc4.dim("None. Start one with")} ${pc4.cyan("fennec start <cmd> --name <name> --restart")}`);
3712
+ console.error(
3713
+ ` ${pc4.dim("None. Start one with")} ${pc4.cyan("fennec start <cmd> --name <name> --restart")}`
3714
+ );
3472
3715
  } else {
3473
3716
  for (const t of managed) {
3474
3717
  const running = isTrackedRunning(t);
@@ -3489,6 +3732,10 @@ async function runSupervisor() {
3489
3732
  return;
3490
3733
  }
3491
3734
  writeFileSync2(pidPath, String(process.pid), "utf-8");
3735
+ try {
3736
+ unlinkSync(`${pidPath}.lock`);
3737
+ } catch {
3738
+ }
3492
3739
  log(`supervisor started (PID ${process.pid}), polling every ${POLL_INTERVAL_MS}ms`);
3493
3740
  const crashes = /* @__PURE__ */ new Map();
3494
3741
  let stopped = false;
@@ -3498,6 +3745,10 @@ async function runSupervisor() {
3498
3745
  unlinkSync(pidPath);
3499
3746
  } catch {
3500
3747
  }
3748
+ try {
3749
+ unlinkSync(`${pidPath}.lock`);
3750
+ } catch {
3751
+ }
3501
3752
  log("supervisor stopping");
3502
3753
  process.exit(0);
3503
3754
  };
@@ -3540,7 +3791,9 @@ async function runSupervisor() {
3540
3791
  rec2.portFails = 0;
3541
3792
  rec2.count++;
3542
3793
  if (rec2.count >= FLAPPING_THRESHOLD) setFlapping(t, true);
3543
- log(`${t.name}: alive but ${probe} not responding ${PORT_FAIL_THRESHOLD}x \u2014 restarting`);
3794
+ log(
3795
+ `${t.name}: alive but ${probe} not responding ${PORT_FAIL_THRESHOLD}x \u2014 restarting`
3796
+ );
3544
3797
  try {
3545
3798
  const newPid = respawnTracked(t, "port-down");
3546
3799
  log(`${t.name}: restarted (PID ${newPid}) after ${probe} failure`);
@@ -3566,14 +3819,18 @@ async function runSupervisor() {
3566
3819
  if (rec.gaveUp) continue;
3567
3820
  if (rec.count >= MAX_RESTARTS_IN_WINDOW) {
3568
3821
  rec.gaveUp = true;
3569
- log(`${t.name}: crashed ${rec.count}x in <60s \u2014 giving up (will retry after window resets)`);
3822
+ log(
3823
+ `${t.name}: crashed ${rec.count}x in <60s \u2014 giving up (will retry after window resets)`
3824
+ );
3570
3825
  continue;
3571
3826
  }
3572
3827
  rec.count++;
3573
3828
  if (rec.count >= FLAPPING_THRESHOLD) setFlapping(t, true);
3574
3829
  try {
3575
3830
  const newPid = respawnTracked(t, "crash");
3576
- log(`${t.name}: restarted (PID ${newPid}) [${rec.count}/${MAX_RESTARTS_IN_WINDOW} in window]`);
3831
+ log(
3832
+ `${t.name}: restarted (PID ${newPid}) [${rec.count}/${MAX_RESTARTS_IN_WINDOW} in window]`
3833
+ );
3577
3834
  } catch (err) {
3578
3835
  log(`${t.name}: restart failed: ${String(err)}`);
3579
3836
  }
@@ -3629,11 +3886,25 @@ function unitPaths(backend) {
3629
3886
  const fennecDir = getFennecDir();
3630
3887
  switch (backend) {
3631
3888
  case "systemd":
3632
- return { unit: resolve3(homedir2(), ".config", "systemd", "user", "fennec-supervisor.service") };
3889
+ return {
3890
+ unit: resolve3(homedir2(), ".config", "systemd", "user", "fennec-supervisor.service")
3891
+ };
3633
3892
  case "launchd":
3634
3893
  return { unit: resolve3(homedir2(), "Library", "LaunchAgents", "io.fennec.supervisor.plist") };
3635
3894
  case "windows":
3636
- return { unit: resolve3(homedir2(), "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "fennec-supervisor.bat") };
3895
+ return {
3896
+ unit: resolve3(
3897
+ homedir2(),
3898
+ "AppData",
3899
+ "Roaming",
3900
+ "Microsoft",
3901
+ "Windows",
3902
+ "Start Menu",
3903
+ "Programs",
3904
+ "Startup",
3905
+ "fennec-supervisor.bat"
3906
+ )
3907
+ };
3637
3908
  default:
3638
3909
  return { unit: resolve3(fennecDir, "persist.unit") };
3639
3910
  }
@@ -3714,7 +3985,12 @@ async function persistCommand(args2) {
3714
3985
  const sub = args2[0] ?? "status";
3715
3986
  const backend = detectBackend();
3716
3987
  if (backend === "unsupported") {
3717
- console.error(renderError("Unsupported platform", `Boot persistence isn't available on ${platform()}. You can still run "fennec supervisor start" manually.`));
3988
+ console.error(
3989
+ renderError(
3990
+ "Unsupported platform",
3991
+ `Boot persistence isn't available on ${platform()}. You can still run "fennec supervisor start" manually.`
3992
+ )
3993
+ );
3718
3994
  process.exit(1);
3719
3995
  }
3720
3996
  switch (sub) {
@@ -3725,7 +4001,9 @@ async function persistCommand(args2) {
3725
4001
  case "status":
3726
4002
  return persistStatus(backend);
3727
4003
  default:
3728
- console.error(renderError("Unknown sub-command", `"${sub}" \u2014 use: enable | disable | status`));
4004
+ console.error(
4005
+ renderError("Unknown sub-command", `"${sub}" \u2014 use: enable | disable | status`)
4006
+ );
3729
4007
  process.exit(1);
3730
4008
  }
3731
4009
  }
@@ -3735,8 +4013,10 @@ function persistEnable(backend) {
3735
4013
  mkdirSync3(dirname3(unit), { recursive: true });
3736
4014
  writeFileSync3(unit, buildUnit(backend, dataDir), "utf-8");
3737
4015
  if (backend === "windows") {
3738
- console.error(`
3739
- ${pc5.green("\u2713")} ${pc5.bold("Boot script installed")} ${pc5.dim(`(runs at next login)`)}`);
4016
+ console.error(
4017
+ `
4018
+ ${pc5.green("\u2713")} ${pc5.bold("Boot script installed")} ${pc5.dim(`(runs at next login)`)}`
4019
+ );
3740
4020
  console.error(` ${renderKV("Path", pc5.cyan(unit))}`);
3741
4021
  console.error(` ${pc5.dim("Supervisor will start automatically when you log in.")}
3742
4022
  `);
@@ -3750,20 +4030,28 @@ function persistEnable(backend) {
3750
4030
  }
3751
4031
  const linger = run("loginctl", ["enable-linger"]);
3752
4032
  if (!linger.ok) {
3753
- console.error(` ${pc5.yellow("\u26A0")} ${pc5.dim("Could not enable user linger \u2014 the supervisor may stop when you log out.")}`);
4033
+ console.error(
4034
+ ` ${pc5.yellow("\u26A0")} ${pc5.dim("Could not enable user linger \u2014 the supervisor may stop when you log out.")}`
4035
+ );
3754
4036
  }
3755
- console.error(`
3756
- ${pc5.green("\u2713")} ${pc5.bold("Boot persistence enabled")} ${pc5.dim("(systemd user service)")}`);
4037
+ console.error(
4038
+ `
4039
+ ${pc5.green("\u2713")} ${pc5.bold("Boot persistence enabled")} ${pc5.dim("(systemd user service)")}`
4040
+ );
3757
4041
  console.error(` ${renderKV("Unit", pc5.cyan("fennec-supervisor.service"))}`);
3758
4042
  console.error(` ${renderKV("Data dir", pc5.cyan(dataDir))}`);
3759
- console.error(` ${pc5.dim("The supervisor (and all --restart apps) will start at login and keep running after logout.")}
3760
- `);
4043
+ console.error(
4044
+ ` ${pc5.dim("The supervisor (and all --restart apps) will start at login and keep running after logout.")}
4045
+ `
4046
+ );
3761
4047
  return;
3762
4048
  }
3763
4049
  if (backend === "launchd") {
3764
4050
  run("launchctl", ["load", resolve3(unit)]);
3765
- console.error(`
3766
- ${pc5.green("\u2713")} ${pc5.bold("Boot persistence enabled")} ${pc5.dim("(launchd agent)")}`);
4051
+ console.error(
4052
+ `
4053
+ ${pc5.green("\u2713")} ${pc5.bold("Boot persistence enabled")} ${pc5.dim("(launchd agent)")}`
4054
+ );
3767
4055
  console.error(` ${renderKV("Label", pc5.cyan("io.fennec.supervisor"))}`);
3768
4056
  console.error(` ${renderKV("Data dir", pc5.cyan(dataDir))}`);
3769
4057
  console.error(` ${pc5.dim("The supervisor will start at login.")}
@@ -3793,7 +4081,9 @@ function persistStatus(backend) {
3793
4081
  console.error(`
3794
4082
  ${pc5.bold("Fennec Boot Persistence")} ${pc5.dim(`(${backend})`)}`);
3795
4083
  if (!installed) {
3796
- console.error(` ${pc5.red("\u25CB")} ${pc5.dim("not installed \u2014 run ")}${pc5.cyan("fennec persist enable")}${pc5.dim(" to auto-start apps after reboot")}`);
4084
+ console.error(
4085
+ ` ${pc5.red("\u25CB")} ${pc5.dim("not installed \u2014 run ")}${pc5.cyan("fennec persist enable")}${pc5.dim(" to auto-start apps after reboot")}`
4086
+ );
3797
4087
  console.error();
3798
4088
  return;
3799
4089
  }
@@ -3822,6 +4112,7 @@ async function psCommand(args2) {
3822
4112
  const systemFlag = args2.includes("--system") || args2.includes("-a") || args2.includes("--all");
3823
4113
  const jsonFlag = args2.includes("--json");
3824
4114
  const nameFilter = args2.includes("--name") ? args2[args2.indexOf("--name") + 1] : void 0;
4115
+ const groupFilter = extractFlagValue(args2, "--group", "-g");
3825
4116
  const sortBy = args2.includes("--sort") ? args2[args2.indexOf("--sort") + 1] : "name";
3826
4117
  if (jsonFlag) {
3827
4118
  await psJson();
@@ -3846,26 +4137,48 @@ async function psCommand(args2) {
3846
4137
  const columns2 = [
3847
4138
  { key: "pid", label: "PID", align: "right", format: (v) => pc6.dim(String(v).padStart(6)) },
3848
4139
  { key: "name", label: "Name", format: (v) => pc6.bold(String(v)) },
3849
- { key: "cpu", label: "CPU%", align: "right", format: (v) => {
3850
- const n = v;
3851
- return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
3852
- } },
3853
- { key: "mem", label: "MEM%", align: "right", format: (v) => {
3854
- const n = v;
3855
- return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
3856
- } },
3857
- { key: "state", label: "State", format: (v) => {
3858
- const s = String(v);
3859
- if (s === "R" || s === "Running") return pc6.green(s);
3860
- if (s === "Z" || s === "Zombie") return pc6.red(s);
3861
- if (s === "S" || s === "Sleeping") return pc6.cyan(s);
3862
- return pc6.dim(s);
3863
- } }
4140
+ {
4141
+ key: "cpu",
4142
+ label: "CPU%",
4143
+ align: "right",
4144
+ format: (v) => {
4145
+ const n = v;
4146
+ return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
4147
+ }
4148
+ },
4149
+ {
4150
+ key: "mem",
4151
+ label: "MEM%",
4152
+ align: "right",
4153
+ format: (v) => {
4154
+ const n = v;
4155
+ return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
4156
+ }
4157
+ },
4158
+ {
4159
+ key: "state",
4160
+ label: "State",
4161
+ format: (v) => {
4162
+ const s = String(v);
4163
+ if (s === "R" || s === "Running") return pc6.green(s);
4164
+ if (s === "Z" || s === "Zombie") return pc6.red(s);
4165
+ if (s === "S" || s === "Sleeping") return pc6.cyan(s);
4166
+ return pc6.dim(s);
4167
+ }
4168
+ }
3864
4169
  ];
3865
- const rows2 = processes.map((p) => ({ pid: p.pid, name: p.name, cpu: p.cpuPercent, mem: p.memPercent, state: formatProcessState(p.state) }));
3866
- console.error(`
4170
+ const rows2 = processes.map((p) => ({
4171
+ pid: p.pid,
4172
+ name: p.name,
4173
+ cpu: p.cpuPercent,
4174
+ mem: p.memPercent,
4175
+ state: formatProcessState(p.state)
4176
+ }));
4177
+ console.error(
4178
+ `
3867
4179
  ${symbols.fox} ${pc6.bold("System Processes")} ${pc6.dim(`(top ${processes.length} by ${sortBy})`)}
3868
- `);
4180
+ `
4181
+ );
3869
4182
  console.error(renderTable(columns2, rows2));
3870
4183
  console.error();
3871
4184
  } catch (error) {
@@ -3874,46 +4187,102 @@ async function psCommand(args2) {
3874
4187
  }
3875
4188
  return;
3876
4189
  }
3877
- const tracked = readTracked();
4190
+ const trackedAll = readTracked();
4191
+ const tracked = groupFilter ? trackedAll.filter((t) => t.group === groupFilter) : trackedAll;
3878
4192
  if (tracked.length === 0) {
3879
- console.error(`
3880
- ${pc6.dim("No tracked processes.")}`);
3881
- console.error(` ${pc6.dim("Start an app with:")} ${pc6.cyan("fennec start <command> --name <name>")}
3882
- `);
4193
+ console.error(
4194
+ `
4195
+ ${pc6.dim(groupFilter ? `No tracked processes in group "${groupFilter}".` : "No tracked processes.")}`
4196
+ );
4197
+ console.error(
4198
+ ` ${pc6.dim("Start an app with:")} ${pc6.cyan("fennec start <command> --name <name>")}
4199
+ `
4200
+ );
3883
4201
  return;
3884
4202
  }
3885
4203
  const columns = [
3886
4204
  { key: "name", label: "App", format: (v) => pc6.bold(String(v)) },
3887
4205
  { key: "pid", label: "PID", align: "right" },
3888
- { key: "status", label: "Status", format: (v) => {
3889
- const s = v;
3890
- return s === "running" ? pc6.green("\u25CF running") : pc6.red("\u25CB stopped");
3891
- } },
3892
- { key: "port", label: "Port", format: (v) => {
3893
- const p = v;
3894
- return p ? pc6.yellow(`:${p}`) : pc6.dim("-");
3895
- } },
3896
- { key: "command", label: "Command", format: (v) => {
3897
- const c = String(v);
3898
- return c.length > 50 ? c.slice(0, 50) + "\u2026" : c;
3899
- } },
4206
+ {
4207
+ key: "status",
4208
+ label: "Status",
4209
+ format: (v) => {
4210
+ const s = v;
4211
+ return s === "running" ? pc6.green("\u25CF running") : pc6.red("\u25CB stopped");
4212
+ }
4213
+ },
4214
+ {
4215
+ key: "group",
4216
+ label: "Group",
4217
+ format: (v) => {
4218
+ const g = String(v);
4219
+ return g === "-" ? pc6.dim("-") : pc6.cyan(g);
4220
+ }
4221
+ },
4222
+ {
4223
+ key: "port",
4224
+ label: "Port",
4225
+ format: (v) => {
4226
+ const p = v;
4227
+ return p ? pc6.yellow(`:${p}`) : pc6.dim("-");
4228
+ }
4229
+ },
4230
+ {
4231
+ key: "mem",
4232
+ label: "MEM",
4233
+ align: "right",
4234
+ format: (v) => {
4235
+ const kb = v;
4236
+ return kb && kb > 0 ? pc6.dim(`${(kb / 1024).toFixed(0)}MB`) : pc6.dim("-");
4237
+ }
4238
+ },
4239
+ {
4240
+ key: "command",
4241
+ label: "Command",
4242
+ format: (v) => {
4243
+ const c = String(v);
4244
+ return c.length > 50 ? c.slice(0, 50) + "\u2026" : c;
4245
+ }
4246
+ },
3900
4247
  { key: "uptime", label: "Uptime", format: (v) => pc6.dim(String(v)) }
3901
4248
  ];
3902
4249
  const rows = tracked.map((t) => {
3903
4250
  const running = isTrackedRunning(t);
3904
4251
  const uptime = running ? formatUptime(Math.floor((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : "-";
3905
- 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 };
4252
+ const memKb = running ? getProcessMemRss(t.pid) ?? null : null;
4253
+ return {
4254
+ name: t.name,
4255
+ pid: running ? String(t.pid) : pc6.dim(String(t.pid)),
4256
+ status: running ? "running" : "stopped",
4257
+ group: t.group ?? "-",
4258
+ port: t.port ?? null,
4259
+ mem: memKb,
4260
+ command: t.command,
4261
+ uptime
4262
+ };
3906
4263
  });
3907
4264
  const runningCount = tracked.filter((t) => isTrackedRunning(t)).length;
3908
- console.error(`
3909
- ${symbols.fox} ${pc6.bold("Fennec Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running)`)}
3910
- `);
4265
+ const scope = groupFilter ? ` in group ${pc6.cyan(groupFilter)}` : "";
4266
+ console.error(
4267
+ `
4268
+ ${symbols.fox} ${pc6.bold("Fennec Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running${scope})`)}
4269
+ `
4270
+ );
3911
4271
  console.error(renderTable(columns, rows));
3912
- console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec start <command> --name <name> --port <port>")} ${pc6.dim("to add more apps.")}`);
4272
+ console.error(
4273
+ ` ${pc6.dim("Use")} ${pc6.cyan("fennec start <command> --name <name> --port <port>")} ${pc6.dim("to add more apps.")}`
4274
+ );
3913
4275
  console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec log <name>")} ${pc6.dim("to view logs.")}`);
3914
- console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec stop <name>")} ${pc6.dim("to pause an app.")}`);
3915
- console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec spawn <name>")} ${pc6.dim("to resume a paused app.")}`);
3916
- console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec kill <name>")} ${pc6.dim("to permanently remove an app.")}`);
4276
+ console.error(
4277
+ ` ${pc6.dim("Use")} ${pc6.cyan("fennec stop <name>")} ${pc6.dim("to pause an app.")}`
4278
+ );
4279
+ console.error(
4280
+ ` ${pc6.dim("Use")} ${pc6.cyan("fennec spawn <name>")} ${pc6.dim("to resume a paused app.")}`
4281
+ );
4282
+ console.error(
4283
+ ` ${pc6.dim("Use")} ${pc6.cyan("fennec kill <name>")} ${pc6.dim("to permanently remove an app.")}`
4284
+ );
4285
+ console.error(` ${pc6.dim("Filter by group with:")} ${pc6.cyan("fennec ps --group <group>")}`);
3917
4286
  console.error();
3918
4287
  }
3919
4288
  async function psJson() {
@@ -3927,6 +4296,11 @@ async function psJson() {
3927
4296
  port: t.port ?? null,
3928
4297
  command: t.command,
3929
4298
  cwd: t.cwd ?? null,
4299
+ group: t.group ?? null,
4300
+ memMB: running ? (() => {
4301
+ const kb = getProcessMemRss(t.pid);
4302
+ return kb ? Math.round(kb / 1024) : null;
4303
+ })() : null,
3930
4304
  startedAt: t.startedAt,
3931
4305
  uptime: running ? Math.floor((Date.now() - new Date(t.startedAt).getTime()) / 1e3) : null
3932
4306
  };
@@ -3943,19 +4317,25 @@ async function statusCommand(_args) {
3943
4317
  `);
3944
4318
  if (tracked.length > 0) {
3945
4319
  const runningCount = tracked.filter((t) => isTrackedRunning(t)).length;
3946
- console.error(` ${pc6.bold("Managed Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running)`)}
3947
- `);
4320
+ console.error(
4321
+ ` ${pc6.bold("Managed Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running)`)}
4322
+ `
4323
+ );
3948
4324
  for (const t of tracked) {
3949
4325
  const running = isTrackedRunning(t);
3950
4326
  const statusIcon = running ? pc6.green("\u25CF") : pc6.red("\u25CB");
3951
4327
  const portStr = t.port ? ` ${pc6.yellow(`:${t.port}`)}` : "";
3952
4328
  const uptime = running ? pc6.dim(formatUptime(Math.floor((Date.now() - new Date(t.startedAt).getTime()) / 1e3))) : pc6.red("stopped");
3953
- console.error(` ${statusIcon} ${pc6.bold(t.name)}${portStr} ${pc6.dim(`(PID ${t.pid})`)} \u2014 ${uptime}`);
4329
+ console.error(
4330
+ ` ${statusIcon} ${pc6.bold(t.name)}${portStr} ${pc6.dim(`(PID ${t.pid})`)} \u2014 ${uptime}`
4331
+ );
3954
4332
  }
3955
4333
  console.error();
3956
4334
  } else {
3957
- console.error(` ${pc6.dim("No managed apps.")} ${pc6.cyan("fennec start <command> --name <name>")}
3958
- `);
4335
+ console.error(
4336
+ ` ${pc6.dim("No managed apps.")} ${pc6.cyan("fennec start <command> --name <name>")}
4337
+ `
4338
+ );
3959
4339
  }
3960
4340
  console.error(` ${pc6.bold("System")} ${pc6.dim(`(${totalUserProcs} user processes)`)}`);
3961
4341
  for (const p of topSystem) {
@@ -3967,25 +4347,47 @@ async function statusCommand(_args) {
3967
4347
  console.error();
3968
4348
  }
3969
4349
  async function watchSystemProcesses(sortBy, limit) {
3970
- console.error(`
4350
+ console.error(
4351
+ `
3971
4352
  ${pc6.bold("Watching system processes")} ${pc6.dim("(Ctrl+C to stop, refreshes every 3s)")}
3972
- `);
4353
+ `
4354
+ );
3973
4355
  const render = () => {
3974
- const processes = getSystemProcesses({ userOnly: true, sortBy, limit });
4356
+ const processes = getSystemProcesses({
4357
+ userOnly: true,
4358
+ sortBy,
4359
+ limit
4360
+ });
3975
4361
  const columns = [
3976
4362
  { key: "pid", label: "PID", align: "right", format: (v) => pc6.dim(String(v).padStart(6)) },
3977
4363
  { key: "name", label: "Name", format: (v) => pc6.bold(String(v)) },
3978
- { key: "cpu", label: "CPU%", align: "right", format: (v) => {
3979
- const n = v;
3980
- return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
3981
- } },
3982
- { key: "mem", label: "MEM%", align: "right", format: (v) => {
3983
- const n = v;
3984
- return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
3985
- } },
4364
+ {
4365
+ key: "cpu",
4366
+ label: "CPU%",
4367
+ align: "right",
4368
+ format: (v) => {
4369
+ const n = v;
4370
+ return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
4371
+ }
4372
+ },
4373
+ {
4374
+ key: "mem",
4375
+ label: "MEM%",
4376
+ align: "right",
4377
+ format: (v) => {
4378
+ const n = v;
4379
+ return n > 10 ? pc6.red(String(n)) : n > 5 ? pc6.yellow(String(n)) : pc6.dim(String(n));
4380
+ }
4381
+ },
3986
4382
  { key: "state", label: "S", align: "center" }
3987
4383
  ];
3988
- const rows = processes.map((p) => ({ pid: p.pid, name: p.name, cpu: p.cpuPercent, mem: p.memPercent, state: p.state }));
4384
+ const rows = processes.map((p) => ({
4385
+ pid: p.pid,
4386
+ name: p.name,
4387
+ cpu: p.cpuPercent,
4388
+ mem: p.memPercent,
4389
+ state: p.state
4390
+ }));
3989
4391
  return ` ${timestamp()} ${pc6.dim(`${processes.length} processes`)}
3990
4392
  ${renderTable(columns, rows, { compact: true })}`;
3991
4393
  };
@@ -3994,11 +4396,11 @@ ${renderTable(columns, rows, { compact: true })}`;
3994
4396
  process.stdout.write("\x1B[J");
3995
4397
  console.error(render());
3996
4398
  }, 3e3);
3997
- await new Promise((resolve13) => {
4399
+ await new Promise((resolve12) => {
3998
4400
  process.once("SIGINT", () => {
3999
4401
  clearInterval(interval);
4000
4402
  process.stdout.write("\x1B[J");
4001
- resolve13();
4403
+ resolve12();
4002
4404
  });
4003
4405
  });
4004
4406
  }
@@ -4040,8 +4442,12 @@ async function startServer(args2) {
4040
4442
  ${pc7.green("\u2713")} ${pc7.bold("Fennec server is running")}`);
4041
4443
  console.error(` ${renderKV("Transport", pc7.cyan("SSE (HTTP)"))}`);
4042
4444
  console.error(` ${renderKV("URL", pc7.cyan(`http://localhost:${port}/sse`))}`);
4043
- console.error(` ${renderKV("MCP Config", pc7.cyan(`{ "type": "remote", "url": "http://localhost:${port}/sse" }`))}`);
4044
- console.error(` ${renderKV("OpenCode", pc7.cyan(`type: remote, url: http://localhost:${port}/sse`))}`);
4445
+ console.error(
4446
+ ` ${renderKV("MCP Config", pc7.cyan(`{ "type": "remote", "url": "http://localhost:${port}/sse" }`))}`
4447
+ );
4448
+ console.error(
4449
+ ` ${renderKV("OpenCode", pc7.cyan(`type: remote, url: http://localhost:${port}/sse`))}`
4450
+ );
4045
4451
  console.error(`
4046
4452
  ${pc7.dim("Press Ctrl+C to stop")}
4047
4453
  `);
@@ -4075,20 +4481,28 @@ async function runCommand(args2) {
4075
4481
  const cwd = cwdIndex !== -1 ? resolve4(args2[cwdIndex + 1]) : process.cwd();
4076
4482
  const restartFlag = args2.includes("--restart");
4077
4483
  const jsonlFlag = args2.includes("--jsonl");
4484
+ const group = extractFlagValue(args2, "--group", "-g");
4078
4485
  const stopFlags = [nameIndex, portIndex, cwdIndex].filter((i) => i !== -1);
4079
4486
  const cmdEnd = Math.min(...stopFlags, Infinity);
4080
4487
  const cmdParts = args2.slice(0, cmdEnd);
4081
4488
  const cmd = cmdParts.join(" ");
4082
4489
  if (!cmd) {
4083
- console.error(renderError("Missing command", "Usage: fennec start|run <command> --name <name> [--port <port>] [--cwd <dir>] [--restart]"));
4490
+ console.error(
4491
+ renderError(
4492
+ "Missing command",
4493
+ "Usage: fennec start|run <command> --name <name> [--port <port>] [--cwd <dir>] [--restart]"
4494
+ )
4495
+ );
4084
4496
  process.exit(1);
4085
4497
  }
4086
4498
  const appName = name ?? cmdParts[0] ?? "app";
4087
4499
  if (port !== void 0) {
4088
4500
  const adopted = adoptExternalOnPort(port, appName);
4089
4501
  if (adopted) {
4090
- console.error(`
4091
- ${pc7.green("\u2713")} ${pc7.bold("Adopted")} ${pc7.bold(adopted.name)} ${pc7.dim(`(PID ${adopted.pid}) \u2014 already listening on :${port}`)}`);
4502
+ console.error(
4503
+ `
4504
+ ${pc7.green("\u2713")} ${pc7.bold("Adopted")} ${pc7.bold(adopted.name)} ${pc7.dim(`(PID ${adopted.pid}) \u2014 already listening on :${port}`)}`
4505
+ );
4092
4506
  console.error(` ${renderKV("Logs", pc7.cyan(`fennec log ${adopted.name}`))}`);
4093
4507
  console.error();
4094
4508
  return;
@@ -4098,7 +4512,9 @@ async function runCommand(args2) {
4098
4512
  const existing = tracked.find((t) => t.name === appName);
4099
4513
  if (existing && isTrackedRunning(existing)) {
4100
4514
  console.error();
4101
- console.error(` ${pc7.yellow("\u26A0")} ${pc7.bold(appName)} ${pc7.dim(`is already running (PID: ${existing.pid})`)}`);
4515
+ console.error(
4516
+ ` ${pc7.yellow("\u26A0")} ${pc7.bold(appName)} ${pc7.dim(`is already running (PID: ${existing.pid})`)}`
4517
+ );
4102
4518
  console.error(` ${renderKV("Logs", pc7.cyan(`fennec log ${appName}`))}`);
4103
4519
  console.error(` ${renderKV("Stop", pc7.cyan(`fennec kill ${appName}`))}`);
4104
4520
  console.error();
@@ -4107,20 +4523,30 @@ async function runCommand(args2) {
4107
4523
  const logDir = dirname4(logFilePathFor(appName));
4108
4524
  mkdirSync4(logDir, { recursive: true });
4109
4525
  const logFilePath = logFilePathFor(appName);
4110
- console.error(`
4526
+ console.error(
4527
+ `
4111
4528
  ${symbols.fox} ${pc7.bold("Starting")} ${renderAppName(appName)} ${pc7.dim("(daemon)")}
4112
- `);
4529
+ `
4530
+ );
4113
4531
  console.error(` ${renderKV("Command", cmd)}`);
4114
4532
  if (port) console.error(` ${renderKV("Port", String(port))}`);
4115
4533
  if (cwd) console.error(` ${renderKV("Directory", cwd)}`);
4116
4534
  if (restartFlag) console.error(` ${renderKV("Auto-restart", pc7.green("enabled"))}`);
4535
+ if (group) console.error(` ${renderKV("Group", group)}`);
4117
4536
  console.error(` ${divider()}`);
4118
4537
  try {
4119
4538
  const startEnv = {};
4120
4539
  for (const [k, v] of Object.entries(process.env)) {
4121
4540
  if (v !== void 0) startEnv[k] = v;
4122
4541
  }
4123
- const currentChild = spawnDaemon({ cmdParts, name: appName, cwd, logFilePath, env: buildSpawnEnv(startEnv), logMode: jsonlFlag ? "jsonl" : "text" });
4542
+ const currentChild = spawnDaemon({
4543
+ cmdParts,
4544
+ name: appName,
4545
+ cwd,
4546
+ logFilePath,
4547
+ env: buildSpawnEnv(startEnv),
4548
+ logMode: jsonlFlag ? "jsonl" : "text"
4549
+ });
4124
4550
  const pid = currentChild.pid ?? 0;
4125
4551
  addTracked({
4126
4552
  name: appName,
@@ -4132,31 +4558,42 @@ async function runCommand(args2) {
4132
4558
  env: startEnv,
4133
4559
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4134
4560
  autoRestart: restartFlag,
4135
- logMode: jsonlFlag ? "jsonl" : "text"
4561
+ logMode: jsonlFlag ? "jsonl" : "text",
4562
+ group
4136
4563
  });
4137
4564
  console.error(` ${pc7.green("\u2713")} ${pc7.bold(appName)} ${pc7.dim(`started (PID: ${pid})`)}`);
4138
- const spinner = createSpinner(port ? `Waiting for ${appName} on port ${port}...` : `Confirming ${appName} is running...`);
4565
+ const spinner = createSpinner(
4566
+ port ? `Waiting for ${appName} on port ${port}...` : `Confirming ${appName} is running...`
4567
+ );
4139
4568
  const ready = await waitForReady(pid, port);
4140
4569
  spinner.stop();
4141
4570
  process.stdout.write("\r\x1B[K");
4142
4571
  if (!ready.running) {
4143
4572
  removeTracked(appName);
4144
- console.error(` ${pc7.red("\u2717")} ${pc7.bold(appName)} ${pc7.dim("exited immediately after start")}`);
4573
+ console.error(
4574
+ ` ${pc7.red("\u2717")} ${pc7.bold(appName)} ${pc7.dim("exited immediately after start")}`
4575
+ );
4145
4576
  console.error(` ${renderKV("Logs", pc7.cyan(`fennec log ${appName}`))}`);
4146
4577
  console.error();
4147
4578
  process.exit(1);
4148
4579
  }
4149
4580
  if (port) {
4150
4581
  if (ready.portReady) {
4151
- console.error(` ${pc7.green("\u2713")} ${pc7.bold(appName)} ${pc7.dim(`is listening on port ${port}`)}`);
4582
+ console.error(
4583
+ ` ${pc7.green("\u2713")} ${pc7.bold(appName)} ${pc7.dim(`is listening on port ${port}`)}`
4584
+ );
4152
4585
  } else {
4153
- console.error(` ${pc7.yellow("\u26A0")} ${pc7.bold(appName)} ${pc7.dim(`is running but port ${port} is not accepting connections yet`)}`);
4586
+ console.error(
4587
+ ` ${pc7.yellow("\u26A0")} ${pc7.bold(appName)} ${pc7.dim(`is running but port ${port} is not accepting connections yet`)}`
4588
+ );
4154
4589
  }
4155
4590
  }
4156
4591
  if (restartFlag) {
4157
4592
  const supPid = ensureSupervisorRunning();
4158
4593
  ensurePersistEnabled();
4159
- console.error(` ${pc7.green("\u2713")} ${pc7.bold("Auto-restart")} ${pc7.dim(`managed by supervisor (PID ${supPid}) \u2014 survives terminal close`)}`);
4594
+ console.error(
4595
+ ` ${pc7.green("\u2713")} ${pc7.bold("Auto-restart")} ${pc7.dim(`managed by supervisor (PID ${supPid}) \u2014 survives terminal close`)}`
4596
+ );
4160
4597
  console.error(` ${renderKV("Supervisor", pc7.cyan("fennec supervisor status"))}`);
4161
4598
  }
4162
4599
  await psCommand([]);
@@ -4181,7 +4618,14 @@ async function resurrectTracked() {
4181
4618
  try {
4182
4619
  const cmdParts = resolveArgs(proc);
4183
4620
  const logFilePath = logFilePathFor(proc.name);
4184
- const child = spawnDaemon({ cmdParts, name: proc.name, cwd: proc.cwd, logFilePath, env: buildSpawnEnv(proc.env), logMode: proc.logMode });
4621
+ const child = spawnDaemon({
4622
+ cmdParts,
4623
+ name: proc.name,
4624
+ cwd: proc.cwd,
4625
+ logFilePath,
4626
+ env: buildSpawnEnv(proc.env),
4627
+ logMode: proc.logMode
4628
+ });
4185
4629
  addTracked({
4186
4630
  name: proc.name,
4187
4631
  pid: child.pid ?? 0,
@@ -4213,25 +4657,49 @@ async function resurrectTracked() {
4213
4657
  // src/commands/kill.ts
4214
4658
  import pc8 from "picocolors";
4215
4659
  async function killCommand(args2) {
4216
- const rawTarget = args2[0];
4217
4660
  const signalIndex = args2.indexOf("--signal");
4218
4661
  const signalRaw = signalIndex !== -1 ? args2[signalIndex + 1] : "SIGTERM";
4219
4662
  const signal = signalRaw ?? "SIGTERM";
4220
4663
  const force = args2.includes("-y") || args2.includes("--yes");
4221
- if (rawTarget === "all" || args2.includes("--all") || args2.includes("-a")) {
4222
- await killAll(signal, force);
4664
+ const target = resolveTargets(args2);
4665
+ if (target.kind === "single") {
4666
+ await killOne(target.value, signal, force, false);
4223
4667
  return;
4224
4668
  }
4225
- if (!rawTarget) {
4226
- console.error(renderError("Missing target", "Usage: fennec kill <pid|name|all> [--signal SIGTERM|SIGKILL|SIGINT]"));
4227
- process.exit(1);
4669
+ if (target.kind === "names") {
4670
+ for (const name of target.values) {
4671
+ await killOne(name, signal, force, true);
4672
+ }
4673
+ return;
4228
4674
  }
4675
+ if (target.kind === "group") {
4676
+ await killGroup(target.group, signal, force);
4677
+ return;
4678
+ }
4679
+ if (target.kind === "all") {
4680
+ await killAll(signal, force);
4681
+ return;
4682
+ }
4683
+ console.error(
4684
+ renderError(
4685
+ "Missing target",
4686
+ "Usage: fennec kill <pid|name|all> [--signal SIGTERM|SIGKILL|SIGINT] [--group <group>] [-y]"
4687
+ )
4688
+ );
4689
+ process.exit(1);
4690
+ }
4691
+ async function killOne(rawTarget, signal, force, multi) {
4229
4692
  let targetPid;
4230
4693
  let displayName;
4231
4694
  const pid = parseInt(rawTarget, 10);
4232
4695
  if (!isNaN(pid) && String(pid) === rawTarget) {
4233
4696
  if (!isProcessRunning(pid)) {
4234
- console.error(renderError("Process not found", `No process with PID ${pid} is running`));
4697
+ const msg = `No process with PID ${pid} is running`;
4698
+ if (multi) {
4699
+ console.error(renderError("Process not found", msg));
4700
+ return;
4701
+ }
4702
+ console.error(renderError("Process not found", msg));
4235
4703
  process.exit(1);
4236
4704
  }
4237
4705
  targetPid = pid;
@@ -4242,16 +4710,23 @@ async function killCommand(args2) {
4242
4710
  if (trackedMatch) {
4243
4711
  if (!isTrackedRunning(trackedMatch)) {
4244
4712
  removeTrackedByPid(trackedMatch.pid);
4245
- console.error(`
4246
- ${pc8.green("\u2713")} ${pc8.bold(rawTarget)} ${pc8.dim("removed from tracked apps (was already stopped)")}`);
4713
+ console.error(
4714
+ `
4715
+ ${pc8.green("\u2713")} ${pc8.bold(rawTarget)} ${pc8.dim("removed from tracked apps (was already stopped)")}`
4716
+ );
4247
4717
  console.error();
4248
4718
  return;
4249
4719
  }
4250
4720
  targetPid = trackedMatch.pid;
4251
4721
  displayName = `${trackedMatch.name} (PID ${targetPid})`;
4252
4722
  } else {
4253
- console.error(renderError("Not tracked", `No tracked process named "${rawTarget}".
4254
- Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4723
+ const msg = `No tracked process named "${rawTarget}".
4724
+ Use ${pc8.cyan("fennec kill <pid>")} to kill a system process by its PID.`;
4725
+ if (multi) {
4726
+ console.error(renderError("Not tracked", msg));
4727
+ return;
4728
+ }
4729
+ console.error(renderError("Not tracked", msg));
4255
4730
  process.exit(1);
4256
4731
  }
4257
4732
  }
@@ -4261,7 +4736,7 @@ Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4261
4736
  return;
4262
4737
  }
4263
4738
  const spinner = createSpinner(`Sending ${signal} to ${displayName}...`);
4264
- const success = killProcess(targetPid, signal);
4739
+ const success = killTree(targetPid, signal);
4265
4740
  if (success) {
4266
4741
  await new Promise((r) => setTimeout(r, 200));
4267
4742
  const stillRunning = isProcessRunning(targetPid);
@@ -4270,12 +4745,12 @@ Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4270
4745
  const forceKill = force || await confirmPrompt(`Send ${pc8.red("SIGKILL")} to force stop?`, true);
4271
4746
  if (forceKill) {
4272
4747
  const forceSpinner = createSpinner(`Sending SIGKILL to ${displayName}...`);
4273
- killProcess(targetPid, "SIGKILL");
4748
+ killTree(targetPid, "SIGKILL");
4274
4749
  await new Promise((r) => setTimeout(r, 300));
4275
4750
  isProcessRunning(targetPid) ? forceSpinner.fail(`${displayName} could not be stopped (permission denied?)`) : forceSpinner.succeed(`${displayName} force stopped`);
4276
4751
  } else {
4277
4752
  console.error(` ${pc8.dim("Retrying with SIGTERM...")}`);
4278
- killProcess(targetPid, "SIGTERM");
4753
+ killTree(targetPid, "SIGTERM");
4279
4754
  console.error(` ${pc8.yellow("\u26A0")} ${displayName} ${pc8.dim("may still be running")}`);
4280
4755
  }
4281
4756
  } else {
@@ -4284,30 +4759,45 @@ Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4284
4759
  }
4285
4760
  } else {
4286
4761
  spinner.fail(`Failed to kill ${displayName}`);
4287
- console.error(renderError("Permission denied", "Try running with sudo or use a different signal."));
4762
+ console.error(
4763
+ renderError("Permission denied", "Try running with sudo or use a different signal.")
4764
+ );
4288
4765
  }
4289
4766
  }
4290
- async function killAll(signal, force) {
4767
+ async function killGroup(group, signal, force) {
4291
4768
  const tracked = readTracked();
4292
- if (tracked.length === 0) {
4293
- console.error(` ${pc8.dim("No tracked apps to kill.")}`);
4294
- return;
4769
+ const inGroup = tracked.filter((t) => t.group === group);
4770
+ if (inGroup.length === 0) {
4771
+ console.error(
4772
+ renderError(
4773
+ "Empty group",
4774
+ `No tracked entries in group "${group}".
4775
+ Known groups: ${pc8.cyan(getGroups().join(", ") || "(none)")}`
4776
+ )
4777
+ );
4778
+ process.exit(1);
4295
4779
  }
4296
- const running = tracked.filter((t) => isTrackedRunning(t));
4297
- const stopped = tracked.filter((t) => !isTrackedRunning(t));
4298
- console.error(`
4299
- ${pc8.bold(`Kill ${running.length} running + remove ${stopped.length} stopped tracked app(s)?`)}`);
4300
- console.error(` ${pc8.dim("Permanently removes every Fennec-tracked app \u2014 other processes are untouched.")}
4301
- `);
4780
+ const running = inGroup.filter((t) => isTrackedRunning(t));
4781
+ const stopped = inGroup.filter((t) => !isTrackedRunning(t));
4782
+ console.error(
4783
+ `
4784
+ ${pc8.bold(`Kill group ${pc8.cyan(group)}: ${running.length} running + remove ${stopped.length} stopped?`)}`
4785
+ );
4786
+ console.error(
4787
+ ` ${pc8.dim("Permanently removes only apps in this group \u2014 other groups are untouched.")}
4788
+ `
4789
+ );
4302
4790
  const confirmed = force || await confirmPrompt(`${pc8.red("Are you sure?")} ${pc8.dim("This cannot be undone")}`, false);
4303
4791
  if (!confirmed) {
4304
4792
  console.error(` ${pc8.dim("Cancelled.")}`);
4305
4793
  return;
4306
4794
  }
4307
- const spinner = createSpinner(`Killing ${running.length} + removing ${stopped.length} tracked app(s)...`);
4795
+ const spinner = createSpinner(
4796
+ `Killing ${running.length} + removing ${stopped.length} tracked app(s) in ${group}...`
4797
+ );
4308
4798
  let killed = 0, failed = 0;
4309
4799
  for (const t of running) {
4310
- if (killProcess(t.pid, signal)) {
4800
+ if (killTree(t.pid, signal)) {
4311
4801
  killed++;
4312
4802
  } else {
4313
4803
  failed++;
@@ -4319,38 +4809,105 @@ async function killAll(signal, force) {
4319
4809
  }
4320
4810
  await new Promise((r) => setTimeout(r, 300));
4321
4811
  const total = running.length + stopped.length;
4322
- total > 0 ? spinner.succeed(`${killed} killed, ${stopped.length} stopped entr${stopped.length === 1 ? "y" : "ies"} removed`) : spinner.fail("Failed to kill apps");
4812
+ total > 0 ? spinner.succeed(
4813
+ `${killed} killed, ${stopped.length} stopped entries removed from group ${group}`
4814
+ ) : spinner.fail("Failed to kill apps");
4323
4815
  if (failed > 0) console.error(` ${pc8.yellow(`${failed} running app(s) could not be killed`)}`);
4324
4816
  }
4325
-
4326
- // src/commands/stop.ts
4327
- import pc9 from "picocolors";
4328
- async function stopCommand(args2) {
4329
- const stopAll = args2.includes("--all") || args2.includes("-a");
4330
- if (stopAll) {
4331
- await stopAllTracked(args2);
4817
+ async function killAll(signal, force) {
4818
+ const tracked = readTracked();
4819
+ if (tracked.length === 0) {
4820
+ console.error(` ${pc8.dim("No tracked apps to kill.")}`);
4332
4821
  return;
4333
4822
  }
4334
- const rawTarget = args2[0];
4335
- if (!rawTarget) {
4336
- console.error(renderError("Missing name", "Usage: fennec stop <name|--all>"));
4337
- process.exit(1);
4338
- }
4339
- const tracked = readTracked();
4340
- const trackedMatch = tracked.find((t) => t.name === rawTarget);
4823
+ const running = tracked.filter((t) => isTrackedRunning(t));
4824
+ const stopped = tracked.filter((t) => !isTrackedRunning(t));
4825
+ console.error(
4826
+ `
4827
+ ${pc8.bold(`Kill ${running.length} running + remove ${stopped.length} stopped tracked app(s)?`)}`
4828
+ );
4829
+ console.error(
4830
+ ` ${pc8.dim("Permanently removes every Fennec-tracked app \u2014 other processes are untouched.")}
4831
+ `
4832
+ );
4833
+ const confirmed = force || await confirmPrompt(`${pc8.red("Are you sure?")} ${pc8.dim("This cannot be undone")}`, false);
4834
+ if (!confirmed) {
4835
+ console.error(` ${pc8.dim("Cancelled.")}`);
4836
+ return;
4837
+ }
4838
+ const spinner = createSpinner(
4839
+ `Killing ${running.length} + removing ${stopped.length} tracked app(s)...`
4840
+ );
4841
+ let killed = 0, failed = 0;
4842
+ for (const t of running) {
4843
+ if (killTree(t.pid, signal)) {
4844
+ killed++;
4845
+ } else {
4846
+ failed++;
4847
+ }
4848
+ removeTrackedByPid(t.pid);
4849
+ }
4850
+ for (const t of stopped) {
4851
+ removeTrackedByPid(t.pid);
4852
+ }
4853
+ await new Promise((r) => setTimeout(r, 300));
4854
+ const total = running.length + stopped.length;
4855
+ total > 0 ? spinner.succeed(`${killed} killed, ${stopped.length} stopped entries removed`) : spinner.fail("Failed to kill apps");
4856
+ if (failed > 0) console.error(` ${pc8.yellow(`${failed} running app(s) could not be killed`)}`);
4857
+ }
4858
+
4859
+ // src/commands/stop.ts
4860
+ import pc9 from "picocolors";
4861
+ async function stopCommand(args2) {
4862
+ const target = resolveTargets(args2);
4863
+ if (target.kind === "group") {
4864
+ await stopAllTracked(args2, target.group);
4865
+ return;
4866
+ }
4867
+ if (target.kind === "all") {
4868
+ await stopAllTracked(args2);
4869
+ return;
4870
+ }
4871
+ if (target.kind === "single") {
4872
+ await stopOne(target.value, args2);
4873
+ return;
4874
+ }
4875
+ if (target.kind === "names") {
4876
+ for (const name of target.values) {
4877
+ await stopOne(name, args2, true);
4878
+ }
4879
+ return;
4880
+ }
4881
+ console.error(
4882
+ renderError("Missing name", "Usage: fennec stop <name|--all> [--group <group>] [-y]")
4883
+ );
4884
+ process.exit(1);
4885
+ }
4886
+ async function stopOne(rawTarget, args2, multi = false) {
4887
+ const tracked = readTracked();
4888
+ const trackedMatch = tracked.find((t) => t.name === rawTarget);
4341
4889
  if (!trackedMatch) {
4342
- console.error(renderError("Not found", `No tracked process named "${rawTarget}". Use ${pc9.cyan("fennec kill <pid>")} to kill a system process.`));
4890
+ const msg = `No tracked process named "${rawTarget}". Use ${pc9.cyan("fennec kill <pid>")} to kill a system process.`;
4891
+ if (multi) {
4892
+ console.error(renderError("Not found", msg));
4893
+ return;
4894
+ }
4895
+ console.error(renderError("Not found", msg));
4343
4896
  process.exit(1);
4344
4897
  }
4345
4898
  if (!isTrackedRunning(trackedMatch)) {
4346
4899
  console.error(`
4347
4900
  ${pc9.yellow("\u26A0")} ${pc9.bold(rawTarget)} ${pc9.dim("is already stopped")}`);
4348
4901
  console.error();
4349
- process.exit(0);
4902
+ if (!multi) process.exit(0);
4903
+ return;
4350
4904
  }
4351
4905
  const displayName = `${trackedMatch.name} (PID ${trackedMatch.pid})`;
4352
4906
  const force = args2.includes("-y") || args2.includes("--yes");
4353
- const confirmed = force || await confirmPrompt(`Stop ${pc9.bold(displayName)}? ${pc9.dim("It can be re-spawned later via fennec spawn")}`, false);
4907
+ const confirmed = force || await confirmPrompt(
4908
+ `Stop ${pc9.bold(displayName)}? ${pc9.dim("It can be re-spawned later via fennec spawn")}`,
4909
+ false
4910
+ );
4354
4911
  if (!confirmed) {
4355
4912
  console.error(` ${pc9.dim("Cancelled")}`);
4356
4913
  return;
@@ -4358,18 +4915,23 @@ async function stopCommand(args2) {
4358
4915
  setAutoRestart(trackedMatch.name, false);
4359
4916
  await stopSingleProcess(trackedMatch.pid, trackedMatch.name);
4360
4917
  }
4361
- async function stopAllTracked(args2) {
4918
+ async function stopAllTracked(args2, group) {
4362
4919
  const tracked = readTracked();
4363
- const running = tracked.filter((t) => isTrackedRunning(t));
4920
+ const running = tracked.filter((t) => isTrackedRunning(t) && (!group || t.group === group));
4364
4921
  if (running.length === 0) {
4365
- console.error(`
4366
- ${pc9.dim("No running tracked processes to stop.")}
4367
- `);
4922
+ console.error(
4923
+ `
4924
+ ${pc9.dim(group ? `No running tracked processes in group "${group}".` : "No running tracked processes to stop.")}
4925
+ `
4926
+ );
4368
4927
  return;
4369
4928
  }
4370
- console.error(`
4371
- ${pc9.yellow("\u26A0")} ${pc9.bold(`Stop all ${running.length} running process(es)?`)} ${pc9.dim("(They can be re-spawned later)")}
4372
- `);
4929
+ const scope = group ? ` group ${pc9.cyan(group)}` : "";
4930
+ console.error(
4931
+ `
4932
+ ${pc9.yellow("\u26A0")} ${pc9.bold(`Stop all ${running.length} running process(es)${scope}?`)} ${pc9.dim("(They can be re-spawned later)")}
4933
+ `
4934
+ );
4373
4935
  for (const t of running) {
4374
4936
  console.error(` ${pc9.green("\u25CF")} ${pc9.bold(t.name)} ${pc9.dim(`(PID ${t.pid})`)}`);
4375
4937
  }
@@ -4386,7 +4948,7 @@ async function stopAllTracked(args2) {
4386
4948
  let failed = 0;
4387
4949
  for (const t of running) {
4388
4950
  setAutoRestart(t.name, false);
4389
- if (killProcess(t.pid, "SIGTERM")) {
4951
+ if (killTree(t.pid, "SIGTERM")) {
4390
4952
  stopped++;
4391
4953
  } else {
4392
4954
  failed++;
@@ -4396,7 +4958,9 @@ async function stopAllTracked(args2) {
4396
4958
  spinner.stop();
4397
4959
  process.stdout.write("\r\x1B[K");
4398
4960
  if (stopped > 0) {
4399
- console.error(` ${pc9.green("\u2713")} ${pc9.bold(`Stopped ${stopped} process(es)`)} ${pc9.dim("(kept in tracked.json)")}`);
4961
+ console.error(
4962
+ ` ${pc9.green("\u2713")} ${pc9.bold(`Stopped ${stopped} process(es)`)} ${pc9.dim("(kept in tracked.json)")}`
4963
+ );
4400
4964
  }
4401
4965
  if (failed > 0) {
4402
4966
  console.error(` ${pc9.red("\u2717")} ${pc9.bold(`${failed} process(es) failed to stop`)}`);
@@ -4407,7 +4971,7 @@ async function stopAllTracked(args2) {
4407
4971
  async function stopSingleProcess(pid, name) {
4408
4972
  const displayName = `${name} (PID ${pid})`;
4409
4973
  const spinner = createSpinner(`Stopping ${displayName}...`);
4410
- const success = killProcess(pid, "SIGTERM");
4974
+ const success = killTree(pid, "SIGTERM");
4411
4975
  if (success) {
4412
4976
  await new Promise((r) => setTimeout(r, 200));
4413
4977
  const stillRunning = isProcessRunning(pid);
@@ -4416,7 +4980,7 @@ async function stopSingleProcess(pid, name) {
4416
4980
  const forceKill = await confirmPrompt(`Send ${pc9.red("SIGKILL")} to force stop?`, true);
4417
4981
  if (forceKill) {
4418
4982
  const forceSpinner = createSpinner(`Sending SIGKILL to ${displayName}...`);
4419
- killProcess(pid, "SIGKILL");
4983
+ killTree(pid, "SIGKILL");
4420
4984
  await new Promise((r) => setTimeout(r, 300));
4421
4985
  isProcessRunning(pid) ? forceSpinner.fail(`${displayName} could not be stopped (permission denied?)`) : forceSpinner.succeed(`${displayName} force stopped`);
4422
4986
  } else {
@@ -4439,38 +5003,77 @@ import { mkdirSync as mkdirSync5 } from "fs";
4439
5003
  import { resolve as resolve5 } from "path";
4440
5004
  import { homedir as homedir3 } from "os";
4441
5005
  async function spawnCommand(args2) {
4442
- const spawnAll = args2.includes("--all") || args2.includes("-a");
4443
- const name = args2[0];
4444
- if (spawnAll) {
4445
- await spawnAllStopped();
5006
+ const target = resolveTargets(args2);
5007
+ if (target.kind === "single") {
5008
+ await spawnOne(target.value, false);
4446
5009
  return;
4447
5010
  }
4448
- if (!name) {
4449
- await spawnList();
5011
+ if (target.kind === "names") {
5012
+ for (const name of target.values) {
5013
+ await spawnOne(name, true);
5014
+ }
5015
+ return;
5016
+ }
5017
+ if (target.kind === "group") {
5018
+ const group = target.group;
5019
+ const tracked = readTracked();
5020
+ const inGroup = tracked.filter((t) => t.group === group && !isTrackedRunning(t) && t.command);
5021
+ if (inGroup.length === 0) {
5022
+ console.error(
5023
+ renderError(
5024
+ "Empty group",
5025
+ `No stopped entries with a saved command in group "${group}".
5026
+ Known groups: ${pc10.cyan(getGroups().join(", ") || "(none)")}`
5027
+ )
5028
+ );
5029
+ process.exit(1);
5030
+ }
5031
+ await spawnAllStopped(inGroup);
5032
+ return;
5033
+ }
5034
+ if (target.kind === "all") {
5035
+ await spawnAllStopped(readTracked().filter((t) => !isTrackedRunning(t) && t.command));
4450
5036
  return;
4451
5037
  }
5038
+ await spawnList();
5039
+ }
5040
+ async function spawnOne(name, multi) {
4452
5041
  const tracked = readTracked();
4453
5042
  const match = tracked.find((t) => t.name === name);
4454
5043
  if (!match) {
4455
- 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.`));
5044
+ const msg = `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.`;
5045
+ if (multi) {
5046
+ console.error(renderError("Not found", msg));
5047
+ return;
5048
+ }
5049
+ console.error(renderError("Not found", msg));
4456
5050
  process.exit(1);
4457
5051
  }
4458
5052
  if (isTrackedRunning(match)) {
4459
- console.error(`
4460
- ${pc10.yellow("\u26A0")} ${pc10.bold(name)} ${pc10.dim("is already running (PID:")} ${match.pid}${pc10.dim(")")}`);
4461
- console.error(` ${pc10.dim("Use")} ${pc10.cyan(`fennec stop ${name}`)} ${pc10.dim("to stop it first.")}`);
5053
+ console.error(
5054
+ `
5055
+ ${pc10.yellow("\u26A0")} ${pc10.bold(name)} ${pc10.dim("is already running (PID:")} ${match.pid}${pc10.dim(")")}`
5056
+ );
5057
+ console.error(
5058
+ ` ${pc10.dim("Use")} ${pc10.cyan(`fennec stop ${name}`)} ${pc10.dim("to stop it first.")}`
5059
+ );
4462
5060
  console.error();
4463
- process.exit(0);
5061
+ if (!multi) process.exit(0);
5062
+ return;
4464
5063
  }
4465
5064
  if (!match.command) {
4466
- console.error(renderError("No command saved", `"${name}" has no saved command and cannot be re-spawned.`));
5065
+ const msg = `"${name}" has no saved command and cannot be re-spawned.`;
5066
+ if (multi) {
5067
+ console.error(renderError("No command saved", msg));
5068
+ return;
5069
+ }
5070
+ console.error(renderError("No command saved", msg));
4467
5071
  process.exit(1);
4468
5072
  }
4469
5073
  await respawnProcess(match);
4470
5074
  }
4471
- async function spawnAllStopped() {
4472
- const tracked = readTracked();
4473
- const toSpawn = tracked.filter((t) => !isTrackedRunning(t) && t.command);
5075
+ async function spawnAllStopped(procs) {
5076
+ const toSpawn = procs;
4474
5077
  if (toSpawn.length === 0) {
4475
5078
  console.error(`
4476
5079
  ${pc10.dim("No stopped processes with saved commands to spawn.")}
@@ -4493,7 +5096,14 @@ async function spawnAllStopped() {
4493
5096
  const logDir = resolve5(homedir3(), ".fennec", "logs");
4494
5097
  mkdirSync5(logDir, { recursive: true });
4495
5098
  const logFilePath = resolve5(logDir, `${proc.name}.log`);
4496
- const child = spawnDaemon({ cmdParts, name: proc.name, cwd: proc.cwd, logFilePath, env: buildSpawnEnv(proc.env), logMode: proc.logMode });
5099
+ const child = spawnDaemon({
5100
+ cmdParts,
5101
+ name: proc.name,
5102
+ cwd: proc.cwd,
5103
+ logFilePath,
5104
+ env: buildSpawnEnv(proc.env),
5105
+ logMode: proc.logMode
5106
+ });
4497
5107
  const pid = child.pid ?? 0;
4498
5108
  addTracked({
4499
5109
  name: proc.name,
@@ -4505,7 +5115,10 @@ async function spawnAllStopped() {
4505
5115
  env: proc.env,
4506
5116
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4507
5117
  autoRestart: proc.autoRestart,
4508
- logMode: proc.logMode
5118
+ logMode: proc.logMode,
5119
+ // Preserve the logical group across stop -> spawn (addTracked REPLACES
5120
+ // the entry by name, so group must be carried over explicitly).
5121
+ group: proc.group
4509
5122
  });
4510
5123
  if (proc.autoRestart) {
4511
5124
  ensureSupervisorRunning();
@@ -4532,7 +5145,9 @@ async function spawnList() {
4532
5145
  if (tracked.length === 0) {
4533
5146
  console.error(`
4534
5147
  ${pc10.dim("No tracked processes found.")}`);
4535
- console.error(` ${pc10.dim("Start an app first:")} ${pc10.cyan("fennec start <command> --name <name>")}`);
5148
+ console.error(
5149
+ ` ${pc10.dim("Start an app first:")} ${pc10.cyan("fennec start <command> --name <name>")}`
5150
+ );
4536
5151
  console.error();
4537
5152
  return;
4538
5153
  }
@@ -4544,8 +5159,10 @@ async function spawnList() {
4544
5159
  console.error();
4545
5160
  return;
4546
5161
  }
4547
- console.error(`
4548
- ${symbols.fox} ${pc10.bold("Available to spawn")} ${pc10.dim(`(${stopped.length}/${tracked.length} stopped)`)}`);
5162
+ console.error(
5163
+ `
5164
+ ${symbols.fox} ${pc10.bold("Available to spawn")} ${pc10.dim(`(${stopped.length}/${tracked.length} stopped)`)}`
5165
+ );
4549
5166
  if (running.length > 0) {
4550
5167
  console.error(` ${pc10.dim("Running (use stop first):")}`);
4551
5168
  for (const r of running) {
@@ -4553,11 +5170,14 @@ async function spawnList() {
4553
5170
  }
4554
5171
  console.error();
4555
5172
  }
4556
- const selected = await selectPrompt("Select a process to spawn:", stopped.map((t) => ({
4557
- value: t.name,
4558
- label: t.name,
4559
- description: t.command.length > 80 ? t.command.slice(0, 80) + "..." : t.command
4560
- })));
5173
+ const selected = await selectPrompt(
5174
+ "Select a process to spawn:",
5175
+ stopped.map((t) => ({
5176
+ value: t.name,
5177
+ label: t.name,
5178
+ description: t.command.length > 80 ? t.command.slice(0, 80) + "..." : t.command
5179
+ }))
5180
+ );
4561
5181
  if (selected === null) {
4562
5182
  console.error(` ${pc10.dim("Cancelled")}`);
4563
5183
  return;
@@ -4572,14 +5192,23 @@ async function respawnProcess(proc) {
4572
5192
  const logDir = resolve5(homedir3(), ".fennec", "logs");
4573
5193
  mkdirSync5(logDir, { recursive: true });
4574
5194
  const logFilePath = resolve5(logDir, `${proc.name}.log`);
4575
- console.error(`
5195
+ console.error(
5196
+ `
4576
5197
  ${symbols.fox} ${pc10.bold("Spawning")} ${renderAppName(proc.name)} ${pc10.dim("(from saved config)")}
4577
- `);
5198
+ `
5199
+ );
4578
5200
  console.error(` ${renderKV("Command", proc.command)}`);
4579
5201
  if (proc.cwd) console.error(` ${renderKV("Directory", proc.cwd)}`);
4580
5202
  if (proc.port) console.error(` ${renderKV("Port", String(proc.port))}`);
4581
5203
  try {
4582
- const child = spawnDaemon({ cmdParts, name: proc.name, cwd: proc.cwd, logFilePath, env: buildSpawnEnv(proc.env), logMode: proc.logMode });
5204
+ const child = spawnDaemon({
5205
+ cmdParts,
5206
+ name: proc.name,
5207
+ cwd: proc.cwd,
5208
+ logFilePath,
5209
+ env: buildSpawnEnv(proc.env),
5210
+ logMode: proc.logMode
5211
+ });
4583
5212
  const pid = child.pid ?? 0;
4584
5213
  addTracked({
4585
5214
  name: proc.name,
@@ -4591,7 +5220,8 @@ async function respawnProcess(proc) {
4591
5220
  env: proc.env,
4592
5221
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4593
5222
  autoRestart: proc.autoRestart,
4594
- logMode: proc.logMode
5223
+ logMode: proc.logMode,
5224
+ group: proc.group
4595
5225
  });
4596
5226
  if (proc.autoRestart) {
4597
5227
  ensureSupervisorRunning();
@@ -4607,32 +5237,51 @@ async function respawnProcess(proc) {
4607
5237
 
4608
5238
  // src/commands/restart.ts
4609
5239
  import pc11 from "picocolors";
4610
- import { mkdirSync as mkdirSync6 } from "fs";
4611
- import { resolve as resolve6 } from "path";
4612
- import { homedir as homedir4 } from "os";
4613
5240
  async function restartCommand(args2) {
4614
- const raw = args2[0];
4615
- if (!raw) {
4616
- console.error(renderError("Missing process name/pid", "Usage: fennec restart <name|pid> [-y]"));
4617
- process.exit(1);
5241
+ const force = args2.includes("-y") || args2.includes("--yes");
5242
+ const target = resolveTargets(args2);
5243
+ if (target.kind === "group") {
5244
+ await restartGroup(target.group, force);
5245
+ return;
5246
+ }
5247
+ if (target.kind === "names") {
5248
+ for (const n of target.values) await restartOne(n, force, true);
5249
+ return;
4618
5250
  }
5251
+ if (target.kind === "single") {
5252
+ await restartOne(target.value, force, false);
5253
+ return;
5254
+ }
5255
+ console.error(
5256
+ renderError(
5257
+ "Missing process name/pid",
5258
+ "Usage: fennec restart <name|pid> [-y] [--group <group>]"
5259
+ )
5260
+ );
5261
+ process.exit(1);
5262
+ }
5263
+ async function restartOne(raw, force, multi) {
4619
5264
  const tracked = readTracked();
4620
5265
  const pidNum = parseInt(raw, 10);
4621
5266
  const trackedEntry = tracked.find((t) => t.name === raw) ?? (!isNaN(pidNum) && String(pidNum) === raw ? tracked.find((t) => t.pid === pidNum) : void 0);
4622
5267
  if (!trackedEntry) {
4623
- console.error(renderError("Not tracked", `No tracked process named or with PID "${raw}".
4624
- fennec restart only re-spawns Fennec-tracked apps (from their saved config).`));
5268
+ const msg = `No tracked process named or with PID "${raw}".
5269
+ fennec restart only re-spawns Fennec-tracked apps (from their saved config).`;
5270
+ if (multi) {
5271
+ console.error(renderError("Not tracked", msg));
5272
+ return;
5273
+ }
5274
+ console.error(renderError("Not tracked", msg));
4625
5275
  process.exit(1);
4626
5276
  }
4627
5277
  const targetPid = trackedEntry.pid;
4628
- const force = args2.includes("-y") || args2.includes("--yes");
4629
5278
  const confirmed = force || await confirmPrompt(`Restart ${pc11.bold(`${trackedEntry.name} (PID ${targetPid})`)}?`, false);
4630
5279
  if (!confirmed) {
4631
5280
  console.error(` ${pc11.dim("Cancelled")}`);
4632
5281
  return;
4633
5282
  }
4634
5283
  const spinner = createSpinner(`Stopping ${trackedEntry.name} (PID ${targetPid})...`);
4635
- const killed = killProcess(targetPid, "SIGTERM");
5284
+ const killed = killTree(targetPid, "SIGTERM");
4636
5285
  if (!killed) {
4637
5286
  spinner.fail(`Failed to stop PID ${targetPid}`);
4638
5287
  return;
@@ -4640,17 +5289,22 @@ fennec restart only re-spawns Fennec-tracked apps (from their saved config).`));
4640
5289
  await new Promise((r) => setTimeout(r, 1e3));
4641
5290
  if (isProcessRunning(targetPid)) {
4642
5291
  spinner.warn("Process didn't stop, sending SIGKILL...");
4643
- killProcess(targetPid, "SIGKILL");
5292
+ killTree(targetPid, "SIGKILL");
4644
5293
  await new Promise((r) => setTimeout(r, 500));
4645
5294
  }
4646
5295
  spinner.succeed(`${trackedEntry.name} stopped`);
4647
5296
  const respawnSpinner = createSpinner(`Re-spawning ${trackedEntry.name}...`);
4648
5297
  try {
4649
5298
  const cmdParts = resolveArgs(trackedEntry);
4650
- const logDir = resolve6(homedir4(), ".fennec", "logs");
4651
- mkdirSync6(logDir, { recursive: true });
4652
- const logFilePath = resolve6(logDir, `${trackedEntry.name}.log`);
4653
- const child = spawnDaemon({ cmdParts, name: trackedEntry.name, cwd: trackedEntry.cwd, logFilePath, env: buildSpawnEnv(trackedEntry.env), logMode: trackedEntry.logMode });
5299
+ const logFilePath = logFilePathFor(trackedEntry.name);
5300
+ const child = spawnDaemon({
5301
+ cmdParts,
5302
+ name: trackedEntry.name,
5303
+ cwd: trackedEntry.cwd,
5304
+ logFilePath,
5305
+ env: buildSpawnEnv(trackedEntry.env),
5306
+ logMode: trackedEntry.logMode
5307
+ });
4654
5308
  removeTrackedByPid(targetPid);
4655
5309
  addTracked({
4656
5310
  name: trackedEntry.name,
@@ -4662,12 +5316,72 @@ fennec restart only re-spawns Fennec-tracked apps (from their saved config).`));
4662
5316
  env: trackedEntry.env,
4663
5317
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4664
5318
  autoRestart: trackedEntry.autoRestart,
4665
- logMode: trackedEntry.logMode
5319
+ logMode: trackedEntry.logMode,
5320
+ group: trackedEntry.group
4666
5321
  });
4667
5322
  respawnSpinner.succeed(`${trackedEntry.name} restarted (PID: ${child.pid})`);
4668
5323
  } catch (error) {
4669
5324
  respawnSpinner.fail(`Failed to re-spawn ${trackedEntry.name}: ${String(error)}`);
4670
- console.error(` ${pc11.dim("Config preserved. Re-run manually:")} ${renderCommand(trackedEntry.command)}`);
5325
+ console.error(
5326
+ ` ${pc11.dim("Config preserved. Re-run manually:")} ${renderCommand(trackedEntry.command)}`
5327
+ );
5328
+ }
5329
+ }
5330
+ async function restartGroup(group, force) {
5331
+ const tracked = readTracked();
5332
+ const inGroup = tracked.filter((t) => t.group === group);
5333
+ if (inGroup.length === 0) {
5334
+ console.error(
5335
+ renderError(
5336
+ "Empty group",
5337
+ `No tracked entries in group "${group}".
5338
+ Known groups: ${pc11.cyan(getGroups().join(", ") || "(none)")}`
5339
+ )
5340
+ );
5341
+ process.exit(1);
5342
+ }
5343
+ const confirmed = force || await confirmPrompt(
5344
+ `Restart ${pc11.bold(`${inGroup.length}`)} process(es) in group ${pc11.cyan(group)}?`,
5345
+ false
5346
+ );
5347
+ if (!confirmed) {
5348
+ console.error(` ${pc11.dim("Cancelled")}`);
5349
+ return;
5350
+ }
5351
+ for (const trackedEntry of inGroup) {
5352
+ const spinner = createSpinner(`Restarting ${trackedEntry.name}...`);
5353
+ try {
5354
+ const cmdParts = resolveArgs(trackedEntry);
5355
+ const logFilePath = logFilePathFor(trackedEntry.name);
5356
+ const child = spawnDaemon({
5357
+ cmdParts,
5358
+ name: trackedEntry.name,
5359
+ cwd: trackedEntry.cwd,
5360
+ logFilePath,
5361
+ env: buildSpawnEnv(trackedEntry.env),
5362
+ logMode: trackedEntry.logMode
5363
+ });
5364
+ removeTrackedByPid(trackedEntry.pid);
5365
+ addTracked({
5366
+ name: trackedEntry.name,
5367
+ pid: child.pid ?? 0,
5368
+ command: trackedEntry.command,
5369
+ args: cmdParts,
5370
+ port: trackedEntry.port,
5371
+ group: trackedEntry.group,
5372
+ cwd: trackedEntry.cwd,
5373
+ env: trackedEntry.env,
5374
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5375
+ autoRestart: trackedEntry.autoRestart,
5376
+ logMode: trackedEntry.logMode
5377
+ });
5378
+ spinner.succeed(`${trackedEntry.name} restarted (PID: ${child.pid})`);
5379
+ } catch (error) {
5380
+ spinner.fail(`Failed to re-spawn ${trackedEntry.name}: ${String(error)}`);
5381
+ console.error(
5382
+ ` ${pc11.dim("Config preserved. Re-run manually:")} ${renderCommand(trackedEntry.command)}`
5383
+ );
5384
+ }
4671
5385
  }
4672
5386
  }
4673
5387
 
@@ -4676,7 +5390,9 @@ import pc12 from "picocolors";
4676
5390
  function adoptCommand(args2) {
4677
5391
  const pidArg = args2.find((a) => /^\d+$/.test(a));
4678
5392
  if (!pidArg) {
4679
- console.error(renderError("Missing PID", `Usage: fennec adopt <pid> [--name <name>] [--port <port>]`));
5393
+ console.error(
5394
+ renderError("Missing PID", `Usage: fennec adopt <pid> [--name <name>] [--port <port>]`)
5395
+ );
4680
5396
  process.exit(1);
4681
5397
  }
4682
5398
  const pid = parseInt(pidArg, 10);
@@ -4689,8 +5405,10 @@ function adoptCommand(args2) {
4689
5405
  console.error(renderError("Cannot adopt", `No running process with PID ${pid}`));
4690
5406
  process.exit(1);
4691
5407
  }
4692
- console.error(`
4693
- ${symbols.fox} ${pc12.green("\u2713")} ${pc12.bold("Adopted")} ${pc12.bold(entry.name)} ${pc12.dim(`(PID ${pid})`)}`);
5408
+ console.error(
5409
+ `
5410
+ ${symbols.fox} ${pc12.green("\u2713")} ${pc12.bold("Adopted")} ${pc12.bold(entry.name)} ${pc12.dim(`(PID ${pid})`)}`
5411
+ );
4694
5412
  if (entry.port) console.error(` ${pc12.dim("port")} ${pc12.yellow(`:${entry.port}`)}`);
4695
5413
  if (entry.command) console.error(` ${pc12.dim("command")} ${entry.command}`);
4696
5414
  console.error(` ${pc12.dim("status")} now tracked + supervised by the supervisor`);
@@ -4710,7 +5428,10 @@ var SECRET_PATTERNS = [
4710
5428
  // Authorization: <scheme> <token>
4711
5429
  { name: "authorization", re: /\bAuthorization\s*:\s*\S+/gi },
4712
5430
  // Generic API keys
4713
- { name: "apikey", re: /\b(api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key)\s*[:=]\s*\S+/gi },
5431
+ {
5432
+ name: "apikey",
5433
+ re: /\b(api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key)\s*[:=]\s*\S+/gi
5434
+ },
4714
5435
  // AWS
4715
5436
  { name: "aws", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g },
4716
5437
  // Slack tokens
@@ -4726,9 +5447,15 @@ var SECRET_PATTERNS = [
4726
5447
  // Generic long hex/alpha tokens (>=32 chars) in common contexts
4727
5448
  { name: "token", re: /\b(token|secret|password|passwd|pwd|client[_-]?secret)\s*[:=]\s*\S+/gi },
4728
5449
  // Connection strings with credentials
4729
- { name: "connstr", re: /\b(mongodb(\+srv)?|postgres(ql)?|mysql|redis|amqp|sqlserver):\/\/[^\s:]+:[^\s@]+@/gi },
5450
+ {
5451
+ name: "connstr",
5452
+ re: /\b(mongodb(\+srv)?|postgres(ql)?|mysql|redis|amqp|sqlserver):\/\/[^\s:]+:[^\s@]+@/gi
5453
+ },
4730
5454
  // Private key blocks
4731
- { name: "pem", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g }
5455
+ {
5456
+ name: "pem",
5457
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
5458
+ }
4732
5459
  ];
4733
5460
  function redactLine(line, opts = {}) {
4734
5461
  if (opts.enabled === false) return line;
@@ -4846,17 +5573,27 @@ async function inspectCommand(args2) {
4846
5573
  process.exit(1);
4847
5574
  }
4848
5575
  const tracked = readTracked();
4849
- const proc = tracked.find((t) => t.name === target || parseInt(target, 10) === t.pid && !isNaN(parseInt(target, 10)));
5576
+ const proc = tracked.find(
5577
+ (t) => t.name === target || parseInt(target, 10) === t.pid && !isNaN(parseInt(target, 10))
5578
+ );
4850
5579
  if (!proc) {
4851
5580
  console.error(pc13.red(`No tracked app named "${target}".`));
4852
5581
  process.exit(1);
4853
5582
  }
4854
5583
  const running = isTrackedRunning(proc);
4855
5584
  const logPath = logFilePathFor(proc.name);
4856
- const lines = readLogLines(logPath, { tail: Math.max(tail, sinceMs ? MAX_TAIL : 0), sinceMs: sinceMs ?? void 0, redact: true, parseTimestamp: true });
5585
+ const lines = readLogLines(logPath, {
5586
+ tail: Math.max(tail, sinceMs ? MAX_TAIL : 0),
5587
+ sinceMs: sinceMs ?? void 0,
5588
+ redact: true,
5589
+ parseTimestamp: true
5590
+ });
4857
5591
  const recent = lines.slice(-tail);
4858
5592
  const errorLines = lines.filter((l) => classifyLevel(l) === "error").slice(-15);
4859
- const redactedLines = recent.reduce((acc, l) => acc + (redactionCount(stripAnsi(l)) > 0 ? 1 : 0), 0);
5593
+ const redactedLines = recent.reduce(
5594
+ (acc, l) => acc + (redactionCount(stripAnsi(l)) > 0 ? 1 : 0),
5595
+ 0
5596
+ );
4860
5597
  const metrics = running ? readMetrics(proc.pid) : {};
4861
5598
  let portHealthy = null;
4862
5599
  if (proc.port) {
@@ -4871,13 +5608,18 @@ async function inspectCommand(args2) {
4871
5608
  if (plain) {
4872
5609
  const dot = running ? pc13.green("\u25CF") : pc13.red("\u25CB");
4873
5610
  const portStr = proc.port ? ` :${proc.port}${portHealthy === false ? pc13.red(" (port down!)") : portHealthy ? pc13.green(" (up)") : ""}` : "";
4874
- console.error(`
4875
- ${symbols.fox} ${renderAppName(proc.name)}${portStr} ${dot} ${running ? pc13.green(`running PID ${proc.pid}`) : pc13.red("stopped")}`);
4876
- 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))}`);
5611
+ console.error(
5612
+ `
5613
+ ${symbols.fox} ${renderAppName(proc.name)}${portStr} ${dot} ${running ? pc13.green(`running PID ${proc.pid}`) : pc13.red("stopped")}`
5614
+ );
5615
+ console.error(
5616
+ ` ${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))}`
5617
+ );
4877
5618
  if (errorLines.length) {
4878
5619
  console.error(`
4879
5620
  ${pc13.bold(pc13.red("Recent errors:"))}`);
4880
- for (const l of errorLines.slice(-8)) console.error(` ${pc13.red(stripAnsi(l).slice(0, 300))}`);
5621
+ for (const l of errorLines.slice(-8))
5622
+ console.error(` ${pc13.red(stripAnsi(l).slice(0, 300))}`);
4881
5623
  }
4882
5624
  console.error();
4883
5625
  return;
@@ -4904,7 +5646,7 @@ async function inspectCommand(args2) {
4904
5646
  // src/commands/dev.ts
4905
5647
  import pc14 from "picocolors";
4906
5648
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
4907
- import { resolve as resolve7, dirname as dirname5 } from "path";
5649
+ import { resolve as resolve6, dirname as dirname5 } from "path";
4908
5650
  import "os";
4909
5651
  import yaml from "js-yaml";
4910
5652
  function loadConfig(path) {
@@ -4923,7 +5665,7 @@ function loadConfig(path) {
4923
5665
  }
4924
5666
  function resolveCwd(baseDir, cwd) {
4925
5667
  if (!cwd) return baseDir;
4926
- return resolve7(baseDir, cwd);
5668
+ return resolve6(baseDir, cwd);
4927
5669
  }
4928
5670
  async function waitForPort(port, timeoutMs = 3e4) {
4929
5671
  const deadline = Date.now() + timeoutMs;
@@ -4972,7 +5714,7 @@ async function startApp(app, baseDir) {
4972
5714
  const wasRunning = !!(existing && isTrackedRunning(existing));
4973
5715
  if (wasRunning) {
4974
5716
  try {
4975
- process.kill(existing.pid, "SIGTERM");
5717
+ killTree(existing.pid, "SIGTERM");
4976
5718
  } catch {
4977
5719
  }
4978
5720
  }
@@ -5014,7 +5756,7 @@ async function startApp(app, baseDir) {
5014
5756
  async function devCommand(args2) {
5015
5757
  const sub = args2[0] ?? "up";
5016
5758
  const configIndex = args2.indexOf("--config");
5017
- const configPath = configIndex !== -1 ? resolve7(args2[configIndex + 1]) : resolve7(process.cwd(), "fennec.config.yaml");
5759
+ const configPath = configIndex !== -1 ? resolve6(args2[configIndex + 1]) : resolve6(process.cwd(), "fennec.config.yaml");
5018
5760
  if (sub === "down") {
5019
5761
  return devDown();
5020
5762
  }
@@ -5025,7 +5767,9 @@ async function devCommand(args2) {
5025
5767
  return devRestart(args2.slice(1));
5026
5768
  }
5027
5769
  if (sub !== "up") {
5028
- console.error(renderError("Unknown sub-command", `"${sub}" \u2014 use: up | down | status | restart`));
5770
+ console.error(
5771
+ renderError("Unknown sub-command", `"${sub}" \u2014 use: up | down | status | restart`)
5772
+ );
5029
5773
  process.exit(1);
5030
5774
  }
5031
5775
  const config = loadConfig(configPath);
@@ -5043,14 +5787,18 @@ async function devCommand(args2) {
5043
5787
  if (p) {
5044
5788
  const prev = portOwner.get(p);
5045
5789
  if (prev) {
5046
- console.error(renderError("Port conflict", `apps "${prev}" and "${a.name}" both declare port :${p}`));
5790
+ console.error(
5791
+ renderError("Port conflict", `apps "${prev}" and "${a.name}" both declare port :${p}`)
5792
+ );
5047
5793
  process.exit(1);
5048
5794
  }
5049
5795
  portOwner.set(p, a.name);
5050
5796
  }
5051
5797
  }
5052
- console.error(`
5053
- ${symbols.fox} ${pc14.bold("fennec dev up")} ${pc14.dim(`\u2014 ${apps.length} app(s) from ${configPath}`)}`);
5798
+ console.error(
5799
+ `
5800
+ ${symbols.fox} ${pc14.bold("fennec dev up")} ${pc14.dim(`\u2014 ${apps.length} app(s) from ${configPath}`)}`
5801
+ );
5054
5802
  ensureSupervisorRunning();
5055
5803
  ensurePersistEnabled();
5056
5804
  for (const app of ordered) {
@@ -5061,7 +5809,10 @@ async function devCommand(args2) {
5061
5809
  const sp = createSpinner(`Waiting for ${dep} :${port}...`);
5062
5810
  const ok = await waitForPort(port);
5063
5811
  sp.stop();
5064
- if (!ok) console.error(` ${pc14.yellow("\u26A0")} ${pc14.dim(`${dep} :${port} not ready in time (continuing)`)}`);
5812
+ if (!ok)
5813
+ console.error(
5814
+ ` ${pc14.yellow("\u26A0")} ${pc14.dim(`${dep} :${port} not ready in time (continuing)`)}`
5815
+ );
5065
5816
  }
5066
5817
  }
5067
5818
  const res = await startApp(app, baseDir);
@@ -5069,8 +5820,10 @@ async function devCommand(args2) {
5069
5820
  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})`);
5070
5821
  console.error(` ${pc14.green("\u2713")} ${pc14.bold(app.name)} ${tag}`);
5071
5822
  }
5072
- console.error(`
5073
- ${pc14.green("\u2713")} Stack up. Observe with ${pc14.cyan("fennec observe")} or ${pc14.cyan("fennec dev status")}.`);
5823
+ console.error(
5824
+ `
5825
+ ${pc14.green("\u2713")} Stack up. Observe with ${pc14.cyan("fennec observe")} or ${pc14.cyan("fennec dev status")}.`
5826
+ );
5074
5827
  console.error(` ${pc14.dim("Apps auto-restart (supervisor) + survive reboot (persist).")}
5075
5828
  `);
5076
5829
  }
@@ -5082,20 +5835,24 @@ function devDown() {
5082
5835
  `);
5083
5836
  return;
5084
5837
  }
5085
- console.error(`
5086
- ${symbols.fox} ${pc14.bold("fennec dev down")} ${pc14.dim(`\u2014 stopping ${tracked.length} app(s)`)}`);
5838
+ console.error(
5839
+ `
5840
+ ${symbols.fox} ${pc14.bold("fennec dev down")} ${pc14.dim(`\u2014 stopping ${tracked.length} app(s)`)}`
5841
+ );
5087
5842
  for (const t of tracked) {
5088
5843
  try {
5089
- if (isTrackedRunning(t)) process.kill(t.pid, "SIGTERM");
5844
+ if (isTrackedRunning(t)) killTree(t.pid, "SIGTERM");
5090
5845
  } catch {
5091
5846
  }
5092
5847
  t.autoRestart = false;
5093
5848
  addTracked({ ...t, autoRestart: false });
5094
5849
  console.error(` ${pc14.red("\u25A0")} ${pc14.bold(t.name)}`);
5095
5850
  }
5096
- console.error(`
5851
+ console.error(
5852
+ `
5097
5853
  ${pc14.green("\u2713")} Stack stopped (auto-restart disabled). Use ${pc14.cyan("fennec dev up")} to bring it back.
5098
- `);
5854
+ `
5855
+ );
5099
5856
  }
5100
5857
  function devRestart(names) {
5101
5858
  ensureSupervisorRunning();
@@ -5104,7 +5861,12 @@ function devRestart(names) {
5104
5861
  const targets = names.length ? tracked.filter((t) => names.includes(t.name)) : tracked.filter((t) => t.autoRestart);
5105
5862
  const missing = names.filter((n) => !tracked.some((t) => t.name === n));
5106
5863
  if (missing.length) {
5107
- console.error(renderError("Not tracked", `No such app(s): ${missing.join(", ")} \u2014 run "fennec dev status" to list tracked apps.`));
5864
+ console.error(
5865
+ renderError(
5866
+ "Not tracked",
5867
+ `No such app(s): ${missing.join(", ")} \u2014 run "fennec dev status" to list tracked apps.`
5868
+ )
5869
+ );
5108
5870
  }
5109
5871
  if (targets.length === 0) {
5110
5872
  console.error(`
@@ -5112,14 +5874,18 @@ function devRestart(names) {
5112
5874
  `);
5113
5875
  return;
5114
5876
  }
5115
- console.error(`
5116
- ${symbols.fox} ${pc14.bold("fennec dev restart")} ${pc14.dim(`\u2014 ${targets.length} app(s)`)}`);
5877
+ console.error(
5878
+ `
5879
+ ${symbols.fox} ${pc14.bold("fennec dev restart")} ${pc14.dim(`\u2014 ${targets.length} app(s)`)}`
5880
+ );
5117
5881
  for (const t of targets) {
5118
5882
  try {
5119
5883
  const newPid = respawnTracked(t, "manual");
5120
5884
  console.error(` ${pc14.green("\u2713")} ${pc14.bold(t.name)} ${pc14.dim(`restarted (PID ${newPid})`)}`);
5121
5885
  } catch (err) {
5122
- console.error(` ${pc14.red("\u2717")} ${pc14.bold(t.name)} ${pc14.dim(`restart failed: ${String(err)}`)}`);
5886
+ console.error(
5887
+ ` ${pc14.red("\u2717")} ${pc14.bold(t.name)} ${pc14.dim(`restart failed: ${String(err)}`)}`
5888
+ );
5123
5889
  }
5124
5890
  }
5125
5891
  console.error();
@@ -5165,7 +5931,15 @@ function topoOrder(apps) {
5165
5931
  }
5166
5932
 
5167
5933
  // src/commands/log.ts
5168
- import { existsSync as existsSync9, unlinkSync as unlinkSync3, statSync as statSync2, openSync as openSync3, fstatSync, readSync, closeSync as closeSync3 } from "fs";
5934
+ import {
5935
+ existsSync as existsSync9,
5936
+ unlinkSync as unlinkSync3,
5937
+ statSync as statSync3,
5938
+ openSync as openSync3,
5939
+ fstatSync,
5940
+ readSync,
5941
+ closeSync as closeSync3
5942
+ } from "fs";
5169
5943
  import { execSync as execSync2 } from "child_process";
5170
5944
  import pc15 from "picocolors";
5171
5945
  var MAX_LOG_LINES = 500;
@@ -5189,14 +5963,21 @@ async function logCommand(args2) {
5189
5963
  process.exit(1);
5190
5964
  }
5191
5965
  if (levelFilter && !["error", "warn", "info", "debug"].includes(levelFilter)) {
5192
- console.error(renderError("Invalid level", `"${levelFilter}" is not a valid level. Use: error, warn, info, debug`));
5966
+ console.error(
5967
+ renderError(
5968
+ "Invalid level",
5969
+ `"${levelFilter}" is not a valid level. Use: error, warn, info, debug`
5970
+ )
5971
+ );
5193
5972
  process.exit(1);
5194
5973
  }
5195
5974
  const redact = !noRedact;
5196
5975
  let logFilePath = null;
5197
5976
  let displayName = target;
5198
5977
  const tracked = readTracked();
5199
- const trackedMatch = tracked.find((t) => t.name === target || parseInt(target, 10) === t.pid && !isNaN(parseInt(target, 10)));
5978
+ const trackedMatch = tracked.find(
5979
+ (t) => t.name === target || parseInt(target, 10) === t.pid && !isNaN(parseInt(target, 10))
5980
+ );
5200
5981
  if (trackedMatch) {
5201
5982
  displayName = trackedMatch.name;
5202
5983
  logFilePath = logFilePathFor(trackedMatch.name);
@@ -5209,16 +5990,20 @@ async function logCommand(args2) {
5209
5990
  }
5210
5991
  if (clearFlag) {
5211
5992
  if (!logFilePath || !existsSync9(logFilePath)) {
5212
- console.error(`
5993
+ console.error(
5994
+ `
5213
5995
  ${pc15.yellow("\u26A0")} ${pc15.dim("No log file found for")} ${pc15.bold(displayName)}
5214
- `);
5996
+ `
5997
+ );
5215
5998
  process.exit(0);
5216
5999
  }
5217
6000
  try {
5218
6001
  unlinkSync3(logFilePath);
5219
- console.error(`
6002
+ console.error(
6003
+ `
5220
6004
  ${pc15.green("\u2713")} ${pc15.bold("Log cleared")} ${pc15.dim(`\u2014 ${logFilePath}`)}
5221
- `);
6005
+ `
6006
+ );
5222
6007
  } catch (err) {
5223
6008
  console.error(renderError("Failed to clear log", String(err)));
5224
6009
  process.exit(1);
@@ -5238,7 +6023,10 @@ async function logCommand(args2) {
5238
6023
  const pid = trackedMatch?.pid ?? parseInt(target, 10);
5239
6024
  if (!isNaN(pid)) {
5240
6025
  try {
5241
- const output = execSync2(`journalctl --no-pager -n ${Math.max(lineCount, 500)} _PID=${pid} 2>/dev/null || echo ""`, { encoding: "utf-8", timeout: 3e3 });
6026
+ const output = execSync2(
6027
+ `journalctl --no-pager -n ${Math.max(lineCount, 500)} _PID=${pid} 2>/dev/null || echo ""`,
6028
+ { encoding: "utf-8", timeout: 3e3 }
6029
+ );
5242
6030
  logLines = output.trim().split("\n").filter(Boolean).map((l) => redact ? redactLine(stripAnsi(l)) : stripAnsi(l));
5243
6031
  } catch {
5244
6032
  logLines = ["(no logs available)"];
@@ -5253,7 +6041,10 @@ async function logCommand(args2) {
5253
6041
  }
5254
6042
  const running = trackedMatch ? isTrackedRunning(trackedMatch) : false;
5255
6043
  const sliced = logLines.slice(-lineCount);
5256
- const redactedHits = sliced.reduce((acc, l) => acc + (redactionCount(stripAnsi(l)) > 0 ? 1 : 0), 0);
6044
+ const redactedHits = sliced.reduce(
6045
+ (acc, l) => acc + (redactionCount(stripAnsi(l)) > 0 ? 1 : 0),
6046
+ 0
6047
+ );
5257
6048
  const payload = {
5258
6049
  app: displayName,
5259
6050
  running,
@@ -5275,7 +6066,9 @@ async function logCommand(args2) {
5275
6066
  console.error(`
5276
6067
  ${symbols.fox} ${renderAppName(displayName)}${portStr} \u2014 ${statusStr}`);
5277
6068
  if (!running) {
5278
- console.error(` ${pc15.dim("This app is not running. Showing last captured logs.")} ${pc15.cyan(`fennec spawn ${displayName}`)} ${pc15.dim("to restart.")}`);
6069
+ console.error(
6070
+ ` ${pc15.dim("This app is not running. Showing last captured logs.")} ${pc15.cyan(`fennec spawn ${displayName}`)} ${pc15.dim("to restart.")}`
6071
+ );
5279
6072
  }
5280
6073
  }
5281
6074
  const spinner = createSpinner(`Reading logs for ${displayName}...`);
@@ -5292,7 +6085,10 @@ async function logCommand(args2) {
5292
6085
  const pid = trackedMatch?.pid ?? parseInt(target, 10);
5293
6086
  if (!isNaN(pid)) {
5294
6087
  try {
5295
- const output = execSync2(`journalctl --no-pager -n ${Math.max(lineCount, 500)} _PID=${pid} 2>/dev/null || echo ""`, { encoding: "utf-8", timeout: 3e3 });
6088
+ const output = execSync2(
6089
+ `journalctl --no-pager -n ${Math.max(lineCount, 500)} _PID=${pid} 2>/dev/null || echo ""`,
6090
+ { encoding: "utf-8", timeout: 3e3 }
6091
+ );
5296
6092
  logLines = output.trim().split("\n").filter(Boolean).map((l) => redact ? redactLine(stripAnsi(l)) : stripAnsi(l));
5297
6093
  } catch {
5298
6094
  logLines = ["(no logs available)"];
@@ -5307,8 +6103,10 @@ async function logCommand(args2) {
5307
6103
  }
5308
6104
  spinner.stop();
5309
6105
  process.stdout.write("\r\x1B[K");
5310
- console.error(`
5311
- ${symbols.fox} ${pc15.bold("Logs")} ${renderAppName(displayName)} ${pc15.dim(`(last ${logLines.length} line${logLines.length !== 1 ? "s" : ""})${redact ? pc15.dim(" \xB7 secrets redacted") : ""})`)}`);
6106
+ console.error(
6107
+ `
6108
+ ${symbols.fox} ${pc15.bold("Logs")} ${renderAppName(displayName)} ${pc15.dim(`(last ${logLines.length} line${logLines.length !== 1 ? "s" : ""})${redact ? pc15.dim(" \xB7 secrets redacted") : ""})`)}`
6109
+ );
5312
6110
  const sliced = logLines.slice(-lineCount);
5313
6111
  for (const line of sliced) {
5314
6112
  const display = line.length > 300 ? line.slice(0, 300) + "\u2026" : line;
@@ -5334,7 +6132,7 @@ async function logCommand(args2) {
5334
6132
  async function followLog(logFilePath, opts) {
5335
6133
  let lastSize = 0;
5336
6134
  try {
5337
- lastSize = statSync2(logFilePath).size;
6135
+ lastSize = statSync3(logFilePath).size;
5338
6136
  } catch {
5339
6137
  }
5340
6138
  console.error(`
@@ -5384,10 +6182,10 @@ async function followLog(logFilePath, opts) {
5384
6182
  closeSync3(fd);
5385
6183
  }
5386
6184
  }, 400);
5387
- await new Promise((resolve13) => {
6185
+ await new Promise((resolve12) => {
5388
6186
  const stop = () => {
5389
6187
  clearInterval(timer);
5390
- resolve13();
6188
+ resolve12();
5391
6189
  };
5392
6190
  process.once("SIGINT", stop);
5393
6191
  process.once("SIGTERM", stop);
@@ -5398,8 +6196,10 @@ function listAvailableApps() {
5398
6196
  if (tracked.length === 0) {
5399
6197
  console.error(`
5400
6198
  ${symbols.fox} ${pc15.bold("No tracked apps to show logs for.")}`);
5401
- console.error(` ${pc15.dim("Start one with:")} ${pc15.cyan("fennec start <command> --name <name>")}
5402
- `);
6199
+ console.error(
6200
+ ` ${pc15.dim("Start one with:")} ${pc15.cyan("fennec start <command> --name <name>")}
6201
+ `
6202
+ );
5403
6203
  return;
5404
6204
  }
5405
6205
  console.error(`
@@ -5411,12 +6211,18 @@ function listAvailableApps() {
5411
6211
  const state = running ? pc15.green(`running (PID ${t.pid})`) : pc15.red("stopped");
5412
6212
  const portStr = t.port ? ` ${pc15.yellow(`:${t.port}`)}` : "";
5413
6213
  console.error(` ${dot} ${pc15.bold(t.name)}${portStr} ${pc15.dim("\u2014")} ${state}`);
5414
- console.error(` ${pc15.cyan(`fennec log ${t.name}`)} ${pc15.dim("\xB7")} ${pc15.cyan(`fennec log ${t.name} -f`)}`);
6214
+ console.error(
6215
+ ` ${pc15.cyan(`fennec log ${t.name}`)} ${pc15.dim("\xB7")} ${pc15.cyan(`fennec log ${t.name} -f`)}`
6216
+ );
5415
6217
  }
5416
- console.error(`
5417
- ${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")}`);
5418
- 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.")}
5419
- `);
6218
+ console.error(
6219
+ `
6220
+ ${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")}`
6221
+ );
6222
+ console.error(
6223
+ ` ${pc15.dim("AI mode:")} ${pc15.cyan("fennec log <app> --json --since 10m")} ${pc15.dim("\u2014 bounded, redacted, machine-readable. Secrets are redacted by default.")}
6224
+ `
6225
+ );
5420
6226
  }
5421
6227
 
5422
6228
  // src/commands/attach.ts
@@ -5451,37 +6257,253 @@ async function attachCommand(args2) {
5451
6257
  }
5452
6258
  }
5453
6259
 
5454
- // src/commands/sessions.ts
5455
- import { SessionStore } from "@plumpslabs/fennec-core";
6260
+ // src/commands/store.ts
6261
+ import { StoreManager, redactSession } from "@plumpslabs/fennec-core";
6262
+ import "@plumpslabs/fennec-core";
5456
6263
  import pc17 from "picocolors";
5457
- async function sessionsCommand() {
5458
- const store = new SessionStore("./.fennec/sessions");
5459
- const sessions = store.list();
5460
- if (sessions.length === 0) {
6264
+ var KINDS = ["session", "process", "workflow", "plugin", "config", "export"];
6265
+ function formatBytes(n) {
6266
+ if (n < 1024) return `${n} B`;
6267
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
6268
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
6269
+ }
6270
+ function formatAge(ms) {
6271
+ const s = Math.floor(ms / 1e3);
6272
+ if (s < 60) return `${s}s`;
6273
+ const m = Math.floor(s / 60);
6274
+ if (m < 60) return `${m}m`;
6275
+ const h = Math.floor(m / 60);
6276
+ if (h < 24) return `${h}h`;
6277
+ return `${Math.floor(h / 24)}d`;
6278
+ }
6279
+ async function storeCommand(args2) {
6280
+ const local = args2.includes("--local");
6281
+ const showSecrets = args2.includes("--show-secrets");
6282
+ const yes = args2.includes("-y") || args2.includes("--yes");
6283
+ const positional = [];
6284
+ for (const a of args2) {
6285
+ if (a.startsWith("--") || a === "-y") continue;
6286
+ positional.push(a);
6287
+ }
6288
+ const kind = positional[0];
6289
+ const action = positional[1];
6290
+ const name = positional[2];
6291
+ const mgr = new StoreManager(local);
6292
+ mgr.ensure();
6293
+ if (!kind) {
6294
+ const scan = mgr.scan();
6295
+ console.error(
6296
+ `
6297
+ ${symbols.fox} ${pc17.bold("Fennec Store")} ${pc17.dim(local ? "(local ./.fennec)" : "(global)")}
6298
+ `
6299
+ );
6300
+ const columns = [
6301
+ { key: "kind", label: "Kind", format: (v) => pc17.bold(String(v)) },
6302
+ { key: "count", label: "Items", format: (v) => pc17.cyan(String(v)) },
6303
+ { key: "size", label: "Size", format: (v) => pc17.dim(formatBytes(Number(v))) },
6304
+ { key: "oldest", label: "Oldest", format: (v) => pc17.dim(formatAge(Number(v))) }
6305
+ ];
6306
+ const rows = scan.map((e) => ({
6307
+ kind: e.kind,
6308
+ count: e.count,
6309
+ size: e.sizeBytes,
6310
+ oldest: e.oldestMs
6311
+ }));
6312
+ console.error(renderTable(columns, rows));
6313
+ console.error(` ${pc17.dim(mgr.base)}
6314
+ `);
6315
+ return;
6316
+ }
6317
+ if (!KINDS.includes(kind)) {
6318
+ console.error(renderError("Unknown kind", `Kind must be one of: ${KINDS.join(", ")}`));
6319
+ process.exit(1);
6320
+ }
6321
+ if (kind !== "session") {
6322
+ const entry = mgr.scan().find((e) => e.kind === kind);
5461
6323
  console.error(`
5462
- ${pc17.dim("No saved sessions found.")}
6324
+ ${pc17.bold(kind)} ${pc17.dim(`(${entry?.count ?? 0} item(s))`)}
6325
+ `);
6326
+ console.error(` ${pc17.dim(mgr.dirFor(kind))}
6327
+ `);
6328
+ const tip = kind === "process" ? "Managed via `fennec ps` / `fennec kill` / `fennec restart`." : kind === "export" ? "Created by `fennec export` / `fennec import`." : "Read-only in this view (Phase 2).";
6329
+ console.error(` ${pc17.dim(tip)}
5463
6330
  `);
5464
6331
  return;
5465
6332
  }
5466
- const columns = [
5467
- { key: "name", label: "Name", format: (v) => pc17.bold(String(v)) },
5468
- { key: "origin", label: "Origin" },
5469
- { key: "savedAt", label: "Saved", format: (v) => pc17.dim(String(v)) }
5470
- ];
5471
- const rows = sessions.map((s) => ({ name: s.name, origin: s.origin, savedAt: new Date(s.savedAt).toLocaleString() }));
5472
- console.error(`
6333
+ const store = mgr.sessionStore();
6334
+ if (!action || action === "ls" || action === "list") {
6335
+ const sessions = store.list();
6336
+ if (sessions.length === 0) {
6337
+ console.error(
6338
+ `
6339
+ ${pc17.dim(`No saved sessions found${local ? " in ./.fennec/sessions" : " in the global store"}.`)}
6340
+ `
6341
+ );
6342
+ return;
6343
+ }
6344
+ const columns = [
6345
+ { key: "name", label: "Name", format: (v) => pc17.bold(String(v)) },
6346
+ { key: "origin", label: "Origin" },
6347
+ { key: "savedAt", label: "Saved", format: (v) => pc17.dim(String(v)) }
6348
+ ];
6349
+ const rows = sessions.map((s) => ({
6350
+ name: s.name,
6351
+ origin: s.origin,
6352
+ savedAt: new Date(s.savedAt).toLocaleString()
6353
+ }));
6354
+ console.error(`
5473
6355
  ${symbols.fox} ${pc17.bold("Saved Sessions")}
5474
6356
  `);
5475
- console.error(renderTable(columns, rows));
5476
- console.error(` ${pc17.dim(`${sessions.length} session(s)`)}
6357
+ console.error(renderTable(columns, rows));
6358
+ console.error(` ${pc17.dim(`${sessions.length} session(s)`)}
6359
+ `);
6360
+ return;
6361
+ }
6362
+ if (action === "info") {
6363
+ if (!name) {
6364
+ console.error(
6365
+ renderError("Missing name", "Usage: fennec store session info <name> [--show-secrets]")
6366
+ );
6367
+ process.exit(1);
6368
+ }
6369
+ const session = store.load(name);
6370
+ if (!session) {
6371
+ console.error(
6372
+ renderError(
6373
+ "Session not found",
6374
+ `No session named "${name}". Use 'fennec store session' to list.`
6375
+ )
6376
+ );
6377
+ process.exit(1);
6378
+ }
6379
+ const shown = showSecrets ? session : redactSession(session);
6380
+ const cookieDomains = shown.cookies.map((c) => String(c.domain ?? c.name ?? "?")).filter((v, i, a) => a.indexOf(v) === i);
6381
+ let body = "";
6382
+ body += renderKV("Name", shown.name);
6383
+ body += renderKV("Origin", shown.origin);
6384
+ body += renderKV("Saved", new Date(shown.savedAt).toLocaleString());
6385
+ if (shown.metadata) {
6386
+ for (const [k, v] of Object.entries(shown.metadata)) body += renderKV(k, String(v));
6387
+ }
6388
+ body += renderKV(
6389
+ "Cookies",
6390
+ `${shown.cookies.length} (domains: ${cookieDomains.join(", ") || "-"})`
6391
+ );
6392
+ body += renderKV("localStorage keys", String(Object.keys(shown.localStorage).length));
6393
+ body += renderKV("Values", showSecrets ? "shown" : pc17.dim("masked \u2014 use --show-secrets"));
6394
+ console.error(`
6395
+ ${pc17.bold(`Session: ${shown.name}`)}
5477
6396
  `);
6397
+ console.error(renderSection("Details", body));
6398
+ return;
6399
+ }
6400
+ if (action === "rm" || action === "remove" || action === "delete") {
6401
+ if (!name) {
6402
+ console.error(renderError("Missing name", "Usage: fennec store session rm <name>"));
6403
+ process.exit(1);
6404
+ }
6405
+ if (!store.load(name)) {
6406
+ console.error(renderError("Session not found", `No session named "${name}".`));
6407
+ process.exit(1);
6408
+ }
6409
+ const confirmed = yes || await confirmPrompt(`Delete session ${pc17.bold(name)}?`, false);
6410
+ if (!confirmed) {
6411
+ console.error(` ${pc17.dim("Cancelled")}`);
6412
+ return;
6413
+ }
6414
+ const ok = store.delete(name);
6415
+ console.error(
6416
+ ok ? ` ${pc17.green("\u2713")} ${pc17.bold(name)} deleted
6417
+ ` : ` ${pc17.red("\u2717")} failed to delete ${pc17.bold(name)}
6418
+ `
6419
+ );
6420
+ return;
6421
+ }
6422
+ console.error(renderError("Unknown action", "Actions: ls | info <name> | rm <name>"));
6423
+ process.exit(1);
5478
6424
  }
5479
6425
 
5480
- // src/commands/setup.ts
6426
+ // src/commands/doctor.ts
6427
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
6428
+ import { join } from "path";
6429
+ import { execSync as execSync3 } from "child_process";
6430
+ import { StoreManager as StoreManager2 } from "@plumpslabs/fennec-core";
5481
6431
  import pc18 from "picocolors";
6432
+ async function doctorCommand() {
6433
+ const mgr = new StoreManager2(false);
6434
+ const base = mgr.base;
6435
+ const problems = [];
6436
+ const notes = [];
6437
+ if (!mgr.permsSafe()) {
6438
+ problems.push(`Store dir is group/other readable: ${base}
6439
+ Fix: chmod 700 ${base}`);
6440
+ }
6441
+ if (StoreManager2.isSynced(base)) {
6442
+ problems.push(
6443
+ `Store lives under a synced directory: ${base}
6444
+ Auth cookies/tokens in sessions + process launch commands could leak across machines.
6445
+ Prefer a non-synced location (FENNEC_HOME=/non-synced/fennec) or use --local with care.`
6446
+ );
6447
+ }
6448
+ const scan = mgr.scan();
6449
+ const sess = scan.find((s) => s.kind === "session");
6450
+ if (sess && sess.count > 0) {
6451
+ notes.push(
6452
+ `${sess.count} auth session(s) with cookies/localStorage stored on disk.
6453
+ Values are masked by default in 'fennec store session info'; use --show-secrets to reveal.`
6454
+ );
6455
+ }
6456
+ const proc = scan.find((s) => s.kind === "process");
6457
+ if (proc && proc.count > 0) {
6458
+ const tracked = mgr.fileFor("process", "");
6459
+ const cmds = existsSync10(tracked) ? JSON.parse(readFileSync9(tracked, "utf-8")) : [];
6460
+ const secretCmds = cmds.filter(
6461
+ (c) => c.command && /(?:^|[\s;|&])([A-Za-z_][A-Za-z0-9_]*)=/.test(c.command)
6462
+ ).length;
6463
+ if (secretCmds > 0) {
6464
+ notes.push(
6465
+ `${secretCmds} tracked process launch command(s) may embed secrets (KEY=value).
6466
+ Stored in ${tracked} \u2014 keep the store out of synced/committed locations.`
6467
+ );
6468
+ }
6469
+ }
6470
+ const localFen = join(process.cwd(), ".fennec");
6471
+ if (existsSync10(localFen)) {
6472
+ let ignored = false;
6473
+ try {
6474
+ execSync3("git check-ignore -q .fennec", { cwd: process.cwd(), stdio: "ignore" });
6475
+ ignored = true;
6476
+ } catch (err) {
6477
+ ignored = err?.code === "ENOENT";
6478
+ }
6479
+ if (!ignored) {
6480
+ problems.push(
6481
+ `Local ./.fennec exists here and is NOT gitignored.
6482
+ It can hold sessions + tracked processes \u2014 add '.fennec/' to .gitignore or remove it.`
6483
+ );
6484
+ }
6485
+ }
6486
+ console.error(`
6487
+ ${pc18.bold("Fennec Doctor")}
6488
+ `);
6489
+ console.error(` ${pc18.dim("Store:")} ${base}
6490
+ `);
6491
+ if (problems.length === 0 && notes.length === 0) {
6492
+ console.error(` ${pc18.green("\u2713")} No issues found.
6493
+ `);
6494
+ return;
6495
+ }
6496
+ for (const p of problems) console.error(` ${pc18.red("\u2717")} ${p}
6497
+ `);
6498
+ for (const n of notes) console.error(` ${pc18.yellow("!")} ${n}
6499
+ `);
6500
+ }
6501
+
6502
+ // src/commands/setup.ts
6503
+ import pc19 from "picocolors";
5482
6504
  async function setupCommand() {
5483
6505
  console.error(`
5484
- ${symbols.fox} ${pc18.bold("Fennec Setup")}
6506
+ ${symbols.fox} ${pc19.bold("Fennec Setup")}
5485
6507
  `);
5486
6508
  const mcpClient = await selectPrompt("Which MCP client are you using?", [
5487
6509
  { value: "claude", label: "Claude Desktop", description: "Anthropic's AI desktop app" },
@@ -5490,12 +6512,12 @@ async function setupCommand() {
5490
6512
  { value: "other", label: "Other MCP client", description: "Any MCP-compatible client" }
5491
6513
  ]);
5492
6514
  if (!mcpClient) {
5493
- console.error(` ${pc18.dim("Setup cancelled.")}
6515
+ console.error(` ${pc19.dim("Setup cancelled.")}
5494
6516
  `);
5495
6517
  return;
5496
6518
  }
5497
6519
  console.error(`
5498
- ${pc18.green("\u2713")} Selected: ${pc18.bold(mcpClient)}
6520
+ ${pc19.green("\u2713")} Selected: ${pc19.bold(mcpClient)}
5499
6521
  `);
5500
6522
  const configSnippet = `{
5501
6523
  "mcpServers": {
@@ -5505,46 +6527,55 @@ async function setupCommand() {
5505
6527
  }
5506
6528
  }
5507
6529
  }`;
5508
- console.error(` ${pc18.bold("Add this to your MCP client config:")}
5509
- `);
5510
- console.error(` ${pc18.dim("```")}`);
5511
- console.error(configSnippet.split("\n").map((l) => ` ${l}`).join("\n"));
5512
- console.error(` ${pc18.dim("```")}
6530
+ console.error(` ${pc19.bold("Add this to your MCP client config:")}
5513
6531
  `);
5514
- console.error(` ${renderSuccess("Setup complete!")} ${pc18.dim("Run")} ${renderCommand("fennec start")} ${pc18.dim("to begin.")}
6532
+ console.error(` ${pc19.dim("```")}`);
6533
+ console.error(
6534
+ configSnippet.split("\n").map((l) => ` ${l}`).join("\n")
6535
+ );
6536
+ console.error(` ${pc19.dim("```")}
5515
6537
  `);
6538
+ console.error(
6539
+ ` ${renderSuccess("Setup complete!")} ${pc19.dim("Run")} ${renderCommand("fennec start")} ${pc19.dim("to begin.")}
6540
+ `
6541
+ );
5516
6542
  }
5517
6543
 
5518
6544
  // src/commands/management.ts
5519
- import { existsSync as existsSync10, writeFileSync as writeFileSync4 } from "fs";
5520
- import { resolve as resolve8 } from "path";
5521
- import { execSync as execSync3 } from "child_process";
5522
- import pc19 from "picocolors";
6545
+ import { existsSync as existsSync11, writeFileSync as writeFileSync4 } from "fs";
6546
+ import { resolve as resolve7 } from "path";
6547
+ import { execSync as execSync4 } from "child_process";
6548
+ import pc20 from "picocolors";
5523
6549
  async function installBrowsersCommand() {
5524
6550
  console.error(`
5525
- ${pc19.bold("Installing Browser Engines")}
6551
+ ${pc20.bold("Installing Browser Engines")}
5526
6552
  `);
5527
6553
  const spinner = createSpinner("Installing Chromium...");
5528
6554
  try {
5529
- execSync3("npx playwright install chromium", { stdio: "pipe", timeout: 12e4 });
6555
+ execSync4("npx playwright install chromium", { stdio: "pipe", timeout: 12e4 });
5530
6556
  spinner.succeed("Chromium installed successfully");
5531
6557
  } catch {
5532
6558
  spinner.fail("Failed to install Chromium");
5533
- console.error(` ${pc19.yellow("\u2192")} Try running: ${renderCommand("npx playwright install chromium")}`);
6559
+ console.error(
6560
+ ` ${pc20.yellow("\u2192")} Try running: ${renderCommand("npx playwright install chromium")}`
6561
+ );
5534
6562
  }
5535
6563
  console.error(`
5536
- ${pc19.green("\u2713")} Browser installation complete.
6564
+ ${pc20.green("\u2713")} Browser installation complete.
5537
6565
  `);
5538
6566
  }
5539
6567
  async function initCommand() {
5540
6568
  console.error(`
5541
- ${pc19.bold("Initialize Fennec Configuration")}
6569
+ ${pc20.bold("Initialize Fennec Configuration")}
5542
6570
  `);
5543
- const configFile = resolve8("./fennec.config.yaml");
5544
- if (existsSync10(configFile)) {
5545
- const overwrite = await confirmPrompt(`${pc19.yellow("fennec.config.yaml")} already exists. Overwrite?`, false);
6571
+ const configFile = resolve7("./fennec.config.yaml");
6572
+ if (existsSync11(configFile)) {
6573
+ const overwrite = await confirmPrompt(
6574
+ `${pc20.yellow("fennec.config.yaml")} already exists. Overwrite?`,
6575
+ false
6576
+ );
5546
6577
  if (!overwrite) {
5547
- console.error(` ${pc19.dim("Cancelled.")}
6578
+ console.error(` ${pc20.dim("Cancelled.")}
5548
6579
  `);
5549
6580
  return;
5550
6581
  }
@@ -5624,44 +6655,44 @@ logging:
5624
6655
  file: null
5625
6656
  `;
5626
6657
  writeFileSync4(configFile, config, "utf-8");
5627
- spinner.succeed(`Configuration written to ${pc19.bold(configFile)}`);
6658
+ spinner.succeed(`Configuration written to ${pc20.bold(configFile)}`);
5628
6659
  console.error(`
5629
- ${pc19.dim("Edit the file to customize Fennec behavior.")}
6660
+ ${pc20.dim("Edit the file to customize Fennec behavior.")}
5630
6661
  `);
5631
6662
  }
5632
6663
 
5633
6664
  // src/commands/health.ts
5634
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
5635
- import { execSync as execSync4 } from "child_process";
5636
- import { resolve as resolve9 } from "path";
5637
- import { homedir as homedir6 } from "os";
5638
- import pc20 from "picocolors";
6665
+ import { existsSync as existsSync12, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
6666
+ import { execSync as execSync5 } from "child_process";
6667
+ import { resolve as resolve8 } from "path";
6668
+ import { homedir as homedir5 } from "os";
6669
+ import pc21 from "picocolors";
5639
6670
  async function healthCommand() {
5640
6671
  printBanner();
5641
- console.error(` ${pc20.bold("Fennec Health Check")}
6672
+ console.error(` ${pc21.bold("Fennec Health Check")}
5642
6673
  `);
5643
6674
  let adbStatus = "unknown";
5644
6675
  try {
5645
- const result = execSync4("adb --version", {
6676
+ const result = execSync5("adb --version", {
5646
6677
  encoding: "utf-8",
5647
6678
  timeout: 3e3,
5648
6679
  stdio: ["ignore", "pipe", "pipe"]
5649
6680
  });
5650
6681
  const version = result.split("\n")[0]?.trim() ?? "installed";
5651
- adbStatus = pc20.green(version);
6682
+ adbStatus = pc21.green(version);
5652
6683
  } catch {
5653
- adbStatus = pc20.dim("not found") + " (optional)";
6684
+ adbStatus = pc21.dim("not found") + " (optional)";
5654
6685
  }
5655
6686
  const tracked = readTracked();
5656
6687
  const runningCount = tracked.filter((t) => isProcessRunning(t.pid)).length;
5657
- const logDir = resolve9(homedir6(), ".fennec", "logs");
6688
+ const logDir = resolve8(homedir5(), ".fennec", "logs");
5658
6689
  let logSize = "0 B";
5659
6690
  try {
5660
- if (existsSync11(logDir)) {
6691
+ if (existsSync12(logDir)) {
5661
6692
  const files = readdirSync2(logDir);
5662
6693
  let totalBytes = 0;
5663
6694
  for (const f of files) {
5664
- const fStat = statSync3(resolve9(logDir, f));
6695
+ const fStat = statSync4(resolve8(logDir, f));
5665
6696
  totalBytes += fStat.size;
5666
6697
  }
5667
6698
  logSize = totalBytes > 1024 * 1024 ? `${(totalBytes / (1024 * 1024)).toFixed(1)} MB` : `${(totalBytes / 1024).toFixed(1)} KB`;
@@ -5674,7 +6705,7 @@ async function healthCommand() {
5674
6705
  memoryInfo = `${(mem.rss / (1024 * 1024)).toFixed(0)} MB RSS / ${(mem.heapUsed / (1024 * 1024)).toFixed(0)} MB heap`;
5675
6706
  } catch {
5676
6707
  }
5677
- console.error(` ${symbols.fox} ${pc20.bold("System")}
6708
+ console.error(` ${symbols.fox} ${pc21.bold("System")}
5678
6709
  `);
5679
6710
  console.error(` ${renderKV("Node.js", process.version)}`);
5680
6711
  console.error(` ${renderKV("Platform", `${process.platform} ${process.arch}`)}`);
@@ -5682,7 +6713,7 @@ async function healthCommand() {
5682
6713
  console.error(` ${renderKV("ADB", adbStatus)}`);
5683
6714
  console.error(` ${renderKV("PID", String(process.pid))}`);
5684
6715
  console.error();
5685
- console.error(` ${symbols.fox} ${pc20.bold("Processes")}
6716
+ console.error(` ${symbols.fox} ${pc21.bold("Processes")}
5686
6717
  `);
5687
6718
  console.error(` ${renderKV("Tracked", String(tracked.length))}`);
5688
6719
  console.error(` ${renderKV("Running", String(runningCount))}`);
@@ -5690,56 +6721,67 @@ async function healthCommand() {
5690
6721
  console.error();
5691
6722
  const allHealthy = runningCount === tracked.length || tracked.length === 0;
5692
6723
  if (allHealthy) {
5693
- console.error(` ${pc20.green("\u2713")} ${pc20.bold("All systems healthy")}
6724
+ console.error(` ${pc21.green("\u2713")} ${pc21.bold("All systems healthy")}
5694
6725
  `);
5695
6726
  } else {
5696
6727
  const stopped = tracked.length - runningCount;
5697
- console.error(` ${pc20.yellow("\u26A0")} ${pc20.bold(`${stopped} process(es) stopped`)} ${pc20.dim("fennec ps")}
5698
- `);
6728
+ console.error(
6729
+ ` ${pc21.yellow("\u26A0")} ${pc21.bold(`${stopped} process(es) stopped`)} ${pc21.dim("fennec ps")}
6730
+ `
6731
+ );
5699
6732
  }
5700
6733
  }
5701
6734
 
5702
6735
  // src/commands/cleanup.ts
5703
- import pc21 from "picocolors";
6736
+ import pc22 from "picocolors";
5704
6737
  async function cleanupCommand() {
5705
6738
  const tracked = readTracked();
5706
6739
  if (tracked.length === 0) {
5707
6740
  console.error(`
5708
- ${pc21.dim("No tracked processes to clean up.")}
6741
+ ${pc22.dim("No tracked processes to clean up.")}
5709
6742
  `);
5710
6743
  return;
5711
6744
  }
5712
6745
  const toRemove = tracked.filter((t) => !isProcessRunning(t.pid) && !t.command);
5713
6746
  if (toRemove.length === 0) {
5714
- console.error(`
5715
- ${pc21.green("\u2713")} ${pc21.bold("All clean")} ${pc21.dim("\u2014 no dead entries without saved commands.")}
5716
- `);
6747
+ console.error(
6748
+ `
6749
+ ${pc22.green("\u2713")} ${pc22.bold("All clean")} ${pc22.dim("\u2014 no dead entries without saved commands.")}
6750
+ `
6751
+ );
5717
6752
  return;
5718
6753
  }
5719
- console.error(`
5720
- ${pc21.yellow("\u26A0")} ${pc21.bold(`Found ${toRemove.length} dead entr${toRemove.length > 1 ? "ies" : "y"} without saved commands`)}
5721
- `);
6754
+ console.error(
6755
+ `
6756
+ ${pc22.yellow("\u26A0")} ${pc22.bold(`Found ${toRemove.length} dead entr${toRemove.length > 1 ? "ies" : "y"} without saved commands`)}
6757
+ `
6758
+ );
5722
6759
  for (const entry of toRemove) {
5723
- console.error(` ${pc21.dim("\xB7")} ${pc21.bold(entry.name)} ${pc21.dim(`(PID ${entry.pid})`)}`);
6760
+ console.error(` ${pc22.dim("\xB7")} ${pc22.bold(entry.name)} ${pc22.dim(`(PID ${entry.pid})`)}`);
5724
6761
  }
5725
6762
  console.error();
5726
- const confirmed = await confirmPrompt(`Remove ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}?`, false);
6763
+ const confirmed = await confirmPrompt(
6764
+ `Remove ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}?`,
6765
+ false
6766
+ );
5727
6767
  if (!confirmed) {
5728
- console.error(` ${pc21.dim("Cancelled")}
6768
+ console.error(` ${pc22.dim("Cancelled")}
5729
6769
  `);
5730
6770
  return;
5731
6771
  }
5732
6772
  const remaining = tracked.filter((t) => !toRemove.includes(t));
5733
6773
  saveTracked(remaining);
5734
- console.error(`
5735
- ${pc21.green("\u2713")} ${pc21.bold(`Removed ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}`)} ${pc21.dim(`(${remaining.length} remaining)`)}
5736
- `);
6774
+ console.error(
6775
+ `
6776
+ ${pc22.green("\u2713")} ${pc22.bold(`Removed ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}`)} ${pc22.dim(`(${remaining.length} remaining)`)}
6777
+ `
6778
+ );
5737
6779
  }
5738
6780
 
5739
6781
  // src/commands/info.ts
5740
- import pc22 from "picocolors";
5741
- import { resolve as resolve10 } from "path";
5742
- import { homedir as homedir7 } from "os";
6782
+ import pc23 from "picocolors";
6783
+ import { resolve as resolve9 } from "path";
6784
+ import { homedir as homedir6 } from "os";
5743
6785
  async function infoCommand(args2) {
5744
6786
  const name = args2[0];
5745
6787
  if (!name) {
@@ -5753,34 +6795,34 @@ async function infoCommand(args2) {
5753
6795
  process.exit(1);
5754
6796
  }
5755
6797
  const running = isProcessRunning(match.pid);
5756
- const statusIcon = running ? pc22.green("\u25CF") : pc22.red("\u25CB");
5757
- const statusText = running ? pc22.green("running") : pc22.red("stopped");
6798
+ const statusIcon = running ? pc23.green("\u25CF") : pc23.red("\u25CB");
6799
+ const statusText = running ? pc23.green("running") : pc23.red("stopped");
5758
6800
  const uptime = running ? formatUptime(Math.floor((Date.now() - new Date(match.startedAt).getTime()) / 1e3)) : "-";
5759
- const logPath = resolve10(homedir7(), ".fennec", "logs", `${match.name}.log`);
6801
+ const logPath = resolve9(homedir6(), ".fennec", "logs", `${match.name}.log`);
5760
6802
  console.error(`
5761
- ${symbols.fox} ${pc22.bold(match.name)} ${pc22.dim("\u2014 Process Info")}
6803
+ ${symbols.fox} ${pc23.bold(match.name)} ${pc23.dim("\u2014 Process Info")}
5762
6804
  `);
5763
- console.error(` ${renderKVColor("Name", match.name, pc22.bold)}`);
6805
+ console.error(` ${renderKVColor("Name", match.name, pc23.bold)}`);
5764
6806
  console.error(` ${renderKVColor("Status", `${statusIcon} ${statusText}`)}`);
5765
6807
  console.error(` ${renderKVColor("PID", String(match.pid))}`);
5766
6808
  console.error(` ${renderKVColor("Port", match.port ? String(match.port) : "-")}`);
5767
- console.error(` ${renderKVColor("Command", match.command || pc22.dim("(none)"))}`);
6809
+ console.error(` ${renderKVColor("Command", match.command || pc23.dim("(none)"))}`);
5768
6810
  if (match.cwd) console.error(` ${renderKVColor("CWD", match.cwd)}`);
5769
6811
  console.error(` ${renderKVColor("Started", new Date(match.startedAt).toLocaleString())}`);
5770
6812
  console.error(` ${renderKVColor("Uptime", uptime)}`);
5771
6813
  console.error(` ${renderKVColor("Log Path", logPath)}`);
5772
6814
  if (!running && match.command) {
5773
6815
  console.error(`
5774
- ${pc22.dim("Re-spawn with:")} ${pc22.cyan(`fennec spawn ${match.name}`)}`);
6816
+ ${pc23.dim("Re-spawn with:")} ${pc23.cyan(`fennec spawn ${match.name}`)}`);
5775
6817
  }
5776
6818
  console.error();
5777
6819
  }
5778
6820
 
5779
6821
  // src/commands/rename.ts
5780
- import pc23 from "picocolors";
5781
- import { existsSync as existsSync12, renameSync as renameSync2 } from "fs";
5782
- import { resolve as resolve11 } from "path";
5783
- import { homedir as homedir8 } from "os";
6822
+ import pc24 from "picocolors";
6823
+ import { existsSync as existsSync13, renameSync as renameSync2 } from "fs";
6824
+ import { resolve as resolve10 } from "path";
6825
+ import { homedir as homedir7 } from "os";
5784
6826
  async function renameCommand(args2) {
5785
6827
  const [oldName, newName] = args2;
5786
6828
  if (!oldName || !newName) {
@@ -5789,7 +6831,7 @@ async function renameCommand(args2) {
5789
6831
  }
5790
6832
  if (oldName === newName) {
5791
6833
  console.error(`
5792
- ${pc23.yellow("\u26A0")} ${pc23.dim("Old and new names are the same.")}
6834
+ ${pc24.yellow("\u26A0")} ${pc24.dim("Old and new names are the same.")}
5793
6835
  `);
5794
6836
  process.exit(0);
5795
6837
  }
@@ -5804,25 +6846,30 @@ async function renameCommand(args2) {
5804
6846
  process.exit(1);
5805
6847
  }
5806
6848
  console.error(`
5807
- ${symbols.fox} ${pc23.bold("Rename Process")}
6849
+ ${symbols.fox} ${pc24.bold("Rename Process")}
5808
6850
  `);
5809
6851
  console.error(` ${renderKVColor("From", oldName)}`);
5810
6852
  console.error(` ${renderKVColor("To", newName)}`);
5811
6853
  if (isProcessRunning(match.pid)) {
5812
- console.error(` ${pc23.yellow("\u26A0")} ${pc23.dim("Process is still running \u2014 log entries will go to the old file until stopped.")}`);
6854
+ console.error(
6855
+ ` ${pc24.yellow("\u26A0")} ${pc24.dim("Process is still running \u2014 log entries will go to the old file until stopped.")}`
6856
+ );
5813
6857
  }
5814
6858
  console.error();
5815
- const confirmed = await confirmPrompt(`Rename ${pc23.bold(oldName)} ${pc23.dim("\u2192")} ${pc23.bold(newName)}?`, true);
6859
+ const confirmed = await confirmPrompt(
6860
+ `Rename ${pc24.bold(oldName)} ${pc24.dim("\u2192")} ${pc24.bold(newName)}?`,
6861
+ true
6862
+ );
5816
6863
  if (!confirmed) {
5817
- console.error(` ${pc23.dim("Cancelled")}
6864
+ console.error(` ${pc24.dim("Cancelled")}
5818
6865
  `);
5819
6866
  return;
5820
6867
  }
5821
6868
  const spinner = createSpinner(`Renaming ${oldName} \u2192 ${newName}...`);
5822
- const logDir = resolve11(homedir8(), ".fennec", "logs");
5823
- const oldLog = resolve11(logDir, `${oldName}.log`);
5824
- const newLog = resolve11(logDir, `${newName}.log`);
5825
- if (existsSync12(oldLog) && !existsSync12(newLog)) {
6869
+ const logDir = resolve10(homedir7(), ".fennec", "logs");
6870
+ const oldLog = resolve10(logDir, `${oldName}.log`);
6871
+ const newLog = resolve10(logDir, `${newName}.log`);
6872
+ if (existsSync13(oldLog) && !existsSync13(newLog)) {
5826
6873
  try {
5827
6874
  renameSync2(oldLog, newLog);
5828
6875
  } catch {
@@ -5830,14 +6877,14 @@ async function renameCommand(args2) {
5830
6877
  }
5831
6878
  match.name = newName;
5832
6879
  saveTracked(tracked);
5833
- spinner.succeed(`${pc23.bold(oldName)} ${pc23.dim("\u2192")} ${pc23.bold(newName)}`);
6880
+ spinner.succeed(`${pc24.bold(oldName)} ${pc24.dim("\u2192")} ${pc24.bold(newName)}`);
5834
6881
  console.error();
5835
6882
  }
5836
6883
 
5837
6884
  // src/commands/export-import.ts
5838
- import pc24 from "picocolors";
5839
- import { existsSync as existsSync13, writeFileSync as writeFileSync5, readFileSync as readFileSync9 } from "fs";
5840
- import { resolve as resolve12 } from "path";
6885
+ import pc25 from "picocolors";
6886
+ import { existsSync as existsSync14, writeFileSync as writeFileSync5, readFileSync as readFileSync10 } from "fs";
6887
+ import { resolve as resolve11 } from "path";
5841
6888
  async function exportCommand(args2) {
5842
6889
  const tracked = readTracked();
5843
6890
  const fileIndex = args2.indexOf("--file");
@@ -5848,17 +6895,19 @@ async function exportCommand(args2) {
5848
6895
  }
5849
6896
  if (tracked.length === 0) {
5850
6897
  console.error(`
5851
- ${pc24.dim("No tracked processes to export.")}
6898
+ ${pc25.dim("No tracked processes to export.")}
5852
6899
  `);
5853
6900
  return;
5854
6901
  }
5855
6902
  const json = JSON.stringify(tracked, null, 2);
5856
6903
  if (filePath) {
5857
6904
  try {
5858
- writeFileSync5(resolve12(filePath), json, "utf-8");
5859
- console.error(`
5860
- ${pc24.green("\u2713")} ${pc24.bold(`Exported ${tracked.length} process(es)`)} ${pc24.dim(`\u2192 ${filePath}`)}
5861
- `);
6905
+ writeFileSync5(resolve11(filePath), json, "utf-8");
6906
+ console.error(
6907
+ `
6908
+ ${pc25.green("\u2713")} ${pc25.bold(`Exported ${tracked.length} process(es)`)} ${pc25.dim(`\u2192 ${filePath}`)}
6909
+ `
6910
+ );
5862
6911
  } catch (err) {
5863
6912
  console.error(renderError("Export failed", String(err)));
5864
6913
  process.exit(1);
@@ -5873,16 +6922,18 @@ async function importCommand(args2) {
5873
6922
  console.error(renderError("Missing file", "Usage: fennec import <file>"));
5874
6923
  process.exit(1);
5875
6924
  }
5876
- const resolvedPath = resolve12(filePath);
5877
- if (!existsSync13(resolvedPath)) {
6925
+ const resolvedPath = resolve11(filePath);
6926
+ if (!existsSync14(resolvedPath)) {
5878
6927
  console.error(renderError("File not found", `"${filePath}" does not exist.`));
5879
6928
  process.exit(1);
5880
6929
  }
5881
6930
  let imported;
5882
6931
  try {
5883
- imported = JSON.parse(readFileSync9(resolvedPath, "utf-8"));
6932
+ imported = JSON.parse(readFileSync10(resolvedPath, "utf-8"));
5884
6933
  if (!Array.isArray(imported) || imported.length === 0) {
5885
- console.error(renderError("Invalid file", "File must contain a non-empty JSON array of process objects."));
6934
+ console.error(
6935
+ renderError("Invalid file", "File must contain a non-empty JSON array of process objects.")
6936
+ );
5886
6937
  process.exit(1);
5887
6938
  }
5888
6939
  } catch (err) {
@@ -5891,20 +6942,23 @@ async function importCommand(args2) {
5891
6942
  }
5892
6943
  const existing = readTracked();
5893
6944
  console.error(`
5894
- ${symbols.fox} ${pc24.bold("Import Processes")}
6945
+ ${symbols.fox} ${pc25.bold("Import Processes")}
5895
6946
  `);
5896
6947
  console.error(` ${renderKVColor("File", resolvedPath)}`);
5897
6948
  console.error(` ${renderKVColor("Importing", `${imported.length} process(es)`)}`);
5898
6949
  console.error(` ${renderKVColor("Existing", `${existing.length} process(es)`)}`);
5899
6950
  console.error(`
5900
- ${pc24.bold("Processes to import:")}`);
6951
+ ${pc25.bold("Processes to import:")}`);
5901
6952
  for (const p of imported) {
5902
- console.error(` ${pc24.green("+")} ${pc24.bold(p.name)} ${pc24.dim(`(${p.command})`)}`);
6953
+ console.error(` ${pc25.green("+")} ${pc25.bold(p.name)} ${pc25.dim(`(${p.command})`)}`);
5903
6954
  }
5904
6955
  console.error();
5905
- const confirmed = await confirmPrompt("Merge into tracked.json? Existing processes with the same name will be overwritten.", true);
6956
+ const confirmed = await confirmPrompt(
6957
+ "Merge into tracked.json? Existing processes with the same name will be overwritten.",
6958
+ true
6959
+ );
5906
6960
  if (!confirmed) {
5907
- console.error(` ${pc24.dim("Cancelled")}
6961
+ console.error(` ${pc25.dim("Cancelled")}
5908
6962
  `);
5909
6963
  return;
5910
6964
  }
@@ -5918,8 +6972,108 @@ async function importCommand(args2) {
5918
6972
  }
5919
6973
  }
5920
6974
  saveTracked(merged);
5921
- console.error(` ${pc24.green("\u2713")} ${pc24.bold(`Imported ${imported.length} process(es)`)} ${pc24.dim(`(${merged.length} total)`)}
6975
+ console.error(
6976
+ ` ${pc25.green("\u2713")} ${pc25.bold(`Imported ${imported.length} process(es)`)} ${pc25.dim(`(${merged.length} total)`)}
6977
+ `
6978
+ );
6979
+ }
6980
+
6981
+ // src/commands/group.ts
6982
+ import pc26 from "picocolors";
6983
+ async function groupCommand(args2) {
6984
+ const positional = args2.filter((a) => !a.startsWith("-") && !a.includes("="));
6985
+ const unset = args2.includes("--unset");
6986
+ if (positional.length === 0) {
6987
+ const tracked = readTracked();
6988
+ if (tracked.length === 0) {
6989
+ console.error(`
6990
+ ${pc26.dim("No tracked processes.")}
5922
6991
  `);
6992
+ return;
6993
+ }
6994
+ const columns = [
6995
+ { key: "name", label: "App", format: (v) => pc26.bold(String(v)) },
6996
+ {
6997
+ key: "group",
6998
+ label: "Group",
6999
+ format: (v) => {
7000
+ const g = String(v);
7001
+ return g === "-" ? pc26.dim("-") : pc26.cyan(g);
7002
+ }
7003
+ },
7004
+ {
7005
+ key: "command",
7006
+ label: "Command",
7007
+ format: (v) => {
7008
+ const c = String(v);
7009
+ return c.length > 50 ? c.slice(0, 50) + "\u2026" : c;
7010
+ }
7011
+ }
7012
+ ];
7013
+ const rows = tracked.map((t) => ({
7014
+ name: t.name,
7015
+ group: t.group ?? "-",
7016
+ command: t.command
7017
+ }));
7018
+ console.error(
7019
+ `
7020
+ ${pc26.bold("Fennec Apps by Group")} ${pc26.dim(`(${getGroups().length} group(s))`)}
7021
+ `
7022
+ );
7023
+ console.error(renderTable(columns, rows));
7024
+ console.error(` ${pc26.dim("Assign one:")} ${pc26.cyan("fennec group <name> <group>")}`);
7025
+ console.error(
7026
+ ` ${pc26.dim("Assign bulk:")} ${pc26.cyan("fennec group <group> <name...>")} ${pc26.dim("Clear:")} ${pc26.cyan("fennec group <name> --unset")}`
7027
+ );
7028
+ console.error();
7029
+ return;
7030
+ }
7031
+ if (unset) {
7032
+ const name = positional[0];
7033
+ const ok = setGroup(name, void 0);
7034
+ if (!ok) {
7035
+ console.error(renderError("Not found", `No tracked process named "${name}".`));
7036
+ process.exit(1);
7037
+ }
7038
+ console.error(`
7039
+ ${pc26.green("\u2713")} ${pc26.bold(name)} ${pc26.dim("removed from its group")}
7040
+ `);
7041
+ return;
7042
+ }
7043
+ let group;
7044
+ let names;
7045
+ if (positional.length >= 3) {
7046
+ group = positional[0];
7047
+ names = positional.slice(1);
7048
+ } else {
7049
+ const [a, b] = positional;
7050
+ const tracked = readTracked();
7051
+ const aIsApp = tracked.some((t) => t.name === a);
7052
+ if (aIsApp) {
7053
+ names = [a];
7054
+ group = b;
7055
+ } else {
7056
+ group = a;
7057
+ names = [b];
7058
+ }
7059
+ }
7060
+ const okNames = [];
7061
+ const failed = [];
7062
+ for (const n of names) {
7063
+ if (setGroup(n, group)) okNames.push(n);
7064
+ else failed.push(n);
7065
+ }
7066
+ if (okNames.length > 0) {
7067
+ console.error(
7068
+ `
7069
+ ${pc26.green("\u2713")} ${pc26.bold(okNames.join(", "))} ${pc26.dim(`assigned to group`)} ${pc26.cyan(group)}`
7070
+ );
7071
+ }
7072
+ if (failed.length > 0) {
7073
+ console.error(` ${pc26.red("\u2717")} ${pc26.dim(`not found: ${failed.join(", ")}`)}`);
7074
+ }
7075
+ if (okNames.length === 0 && failed.length > 0) process.exit(1);
7076
+ console.error();
5923
7077
  }
5924
7078
 
5925
7079
  // src/index.ts
@@ -5977,6 +7131,8 @@ async function main() {
5977
7131
  await cleanupCommand();
5978
7132
  } else if (command === "rename") {
5979
7133
  await renameCommand(args);
7134
+ } else if (command === "group") {
7135
+ await groupCommand(args);
5980
7136
  } else if (command === "export") {
5981
7137
  await exportCommand(args);
5982
7138
  } else if (command === "import") {
@@ -5991,8 +7147,14 @@ async function main() {
5991
7147
  await attachPortCommand(args);
5992
7148
  } else if (command === "watch") {
5993
7149
  await watchCommand(args);
7150
+ } else if (command === "store") {
7151
+ printBanner();
7152
+ await storeCommand(args);
7153
+ } else if (command === "doctor") {
7154
+ printBanner();
7155
+ await doctorCommand();
5994
7156
  } else if (command === "sessions") {
5995
- await sessionsCommand();
7157
+ await storeCommand(["session"]);
5996
7158
  } else if (command === "setup") {
5997
7159
  await setupCommand();
5998
7160
  } else if (command === "install-browsers") {
@@ -6013,7 +7175,9 @@ async function main() {
6013
7175
  showHelp();
6014
7176
  }
6015
7177
  } else {
6016
- console.error(renderError(`Unknown command: ${command}`, "Run 'fennec help' for usage information"));
7178
+ console.error(
7179
+ renderError(`Unknown command: ${command}`, "Run 'fennec help' for usage information")
7180
+ );
6017
7181
  process.exit(1);
6018
7182
  }
6019
7183
  }