@plumpslabs/fennec-cli 1.14.1 → 1.14.3

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
@@ -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.1";
2102
+ var VERSION = "1.14.3";
2095
2103
  var cachedBanner = null;
2096
2104
  var cachedCompact = null;
2097
2105
  function generateBanner() {
@@ -2158,11 +2166,12 @@ 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
+ ["--restart", "Auto-restart if it crashes or its port stops listening (detached supervisor, survives terminal close)"],
2170
+ ["--group <group>", "Logical group/namespace for bulk ops (kill/spawn/stop --group <group>)"]
2162
2171
  ],
2163
2172
  examples: [
2164
2173
  'start "npm run dev" --name web --port 3000',
2165
- "start node server.js --name api --cwd ./backend --restart"
2174
+ "start node server.js --name api --cwd ./backend --restart --group backend"
2166
2175
  ]
2167
2176
  },
2168
2177
  run: {
@@ -2175,18 +2184,20 @@ var COMMANDS = {
2175
2184
  ["--name <name>", "App name used for tracking (recommended)"],
2176
2185
  ["--port <port>", "Port the app listens on"],
2177
2186
  ["--cwd <dir>", "Working directory"],
2178
- ["--restart", "Auto-restart if it crashes or its port stops listening"]
2187
+ ["--restart", "Auto-restart if it crashes or its port stops listening"],
2188
+ ["--group <group>", "Logical group for bulk ops"]
2179
2189
  ],
2180
- examples: ["run npm start --name web --port 8080"]
2190
+ examples: ["run npm start --name web --port 8080 --group frontend"]
2181
2191
  },
2182
2192
  ps: {
2183
2193
  name: "ps",
2184
2194
  usage: "ps [options]",
2185
2195
  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).",
2196
+ 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
2197
  options: [
2188
2198
  ["--system, -a, --all", "Show all system processes instead of tracked apps"],
2189
2199
  ["--name <name>", "Filter system processes by name"],
2200
+ ["--group <g>, -g <g>", "Show only tracked apps in group <g>"],
2190
2201
  ["--sort <field>", "Sort by cpu | mem | pid | name (default: name)"],
2191
2202
  ["--json", "Output tracked apps as JSON"],
2192
2203
  ["-w, --watch", "Watch mode \u2014 refresh every 3s (with --system)"]
@@ -2219,37 +2230,45 @@ var COMMANDS = {
2219
2230
  },
2220
2231
  spawn: {
2221
2232
  name: "spawn",
2222
- usage: "spawn [name] [--all]",
2233
+ usage: "spawn [name] [name...] [--all] [--group <g>]",
2223
2234
  summary: "Re-spawn a stopped tracked app from its saved config",
2224
2235
  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"]
2236
+ options: [
2237
+ ["--all, -a", "Re-spawn all stopped apps that have a saved command"],
2238
+ ["--group <g>, -g <g>", "Re-spawn only stopped apps in group <g>"]
2239
+ ],
2240
+ examples: ["spawn", "spawn web", "spawn be-crm fe-crm", "spawn --all", "spawn --group backend"]
2227
2241
  },
2228
2242
  stop: {
2229
2243
  name: "stop",
2230
- usage: "stop <name|--all>",
2244
+ usage: "stop <name|--all> [name...] [--group <g>]",
2231
2245
  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"]
2246
+ 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`).",
2247
+ options: [
2248
+ ["--all, -a", "Stop all running tracked apps"],
2249
+ ["--group <g>, -g <g>", "Stop only running apps in group <g>"]
2250
+ ],
2251
+ examples: ["stop web", "stop be-crm fe-crm -y", "stop --all", "stop --group backend"]
2235
2252
  },
2236
2253
  restart: {
2237
2254
  name: "restart",
2238
- usage: "restart <name|pid>",
2255
+ usage: "restart <name|pid> [name...] [--group <g>]",
2239
2256
  summary: "Stop and re-spawn a tracked app from its saved config",
2240
- examples: ["restart web"]
2257
+ options: [["--group <g>, -g <g>", "Restart all apps in group <g>"]],
2258
+ examples: ["restart web", "restart be-crm fe-crm -y", "restart --group backend"]
2241
2259
  },
2242
2260
  kill: {
2243
2261
  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.",
2262
+ usage: "kill <pid|name|all> [name...] [--group <g>]",
2263
+ summary: "Kill a process and remove it from the registry",
2264
+ 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
2265
  options: [
2248
2266
  ["--signal <sig>", "Signal to send (default: SIGTERM). e.g. SIGKILL, SIGINT"],
2249
2267
  ["--all, -a", "Kill ALL tracked apps (asks for confirmation)"],
2268
+ ["--group <g>, -g <g>", "Kill only apps in group <g> (other groups untouched)"],
2250
2269
  ["-y, --yes", "Skip the confirmation prompt"]
2251
2270
  ],
2252
- examples: ["kill web", "kill 12345 --signal SIGKILL", "kill all -y"]
2271
+ examples: ["kill web", "kill 12345 --signal SIGKILL", "kill be-crm fe-crm -y", "kill all -y", "kill --group backend -y"]
2253
2272
  },
2254
2273
  supervisor: {
2255
2274
  name: "supervisor",
@@ -2317,6 +2336,18 @@ var COMMANDS = {
2317
2336
  summary: "Rename a tracked app",
2318
2337
  examples: ["rename web frontend"]
2319
2338
  },
2339
+ group: {
2340
+ name: "group",
2341
+ usage: "group [name] [group]",
2342
+ summary: "Assign a logical group to tracked apps (for scoped bulk ops)",
2343
+ 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.",
2344
+ options: [
2345
+ ["<name> <group>", "Assign <group> to the tracked app <name>"],
2346
+ ["<name> --unset", "Remove the group from <name>"],
2347
+ ["(no args)", "List all tracked apps with their group"]
2348
+ ],
2349
+ examples: ["group web backend", "group web --unset", "group", "kill --group backend"]
2350
+ },
2320
2351
  attach: {
2321
2352
  name: "attach",
2322
2353
  usage: "attach <port> --name <name>",
@@ -2431,7 +2462,7 @@ function findCommandDoc(cmd) {
2431
2462
  }
2432
2463
  var GROUPS = [
2433
2464
  { 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"] },
2465
+ { title: "Apps & Processes", keys: ["start", "run", "ps", "status", "log", "spawn", "stop", "restart", "kill", "group", "supervisor", "persist", "dev", "inspect", "info", "rename"] },
2435
2466
  { title: "Observation", keys: ["attach", "attach-pid", "attach-port", "pipe", "watch"] },
2436
2467
  { title: "Data", keys: ["export", "import", "cleanup"] },
2437
2468
  { title: "Configuration", keys: ["init", "setup", "install-browsers", "sessions"] },
@@ -2872,12 +2903,32 @@ function getSystemProcesses(filter) {
2872
2903
  }
2873
2904
  return processes;
2874
2905
  }
2875
- function killProcess(pid, signal = "SIGTERM") {
2906
+ function killTree(pid, signal = "SIGTERM") {
2907
+ if (process.platform === "win32") {
2908
+ try {
2909
+ execSync(`taskkill /pid ${pid} /T /F`, { stdio: "ignore", timeout: 5e3 });
2910
+ return true;
2911
+ } catch {
2912
+ try {
2913
+ process.kill(pid, signal);
2914
+ return true;
2915
+ } catch {
2916
+ return false;
2917
+ }
2918
+ }
2919
+ }
2876
2920
  try {
2877
- process.kill(pid, signal);
2921
+ process.kill(-pid, signal);
2878
2922
  return true;
2879
- } catch {
2880
- return false;
2923
+ } catch (e) {
2924
+ const code = e.code;
2925
+ if (code === "ESRCH") return false;
2926
+ try {
2927
+ process.kill(pid, signal);
2928
+ return true;
2929
+ } catch {
2930
+ return false;
2931
+ }
2881
2932
  }
2882
2933
  }
2883
2934
  function isProcessRunning(pid) {
@@ -2888,15 +2939,48 @@ function isProcessRunning(pid) {
2888
2939
  return false;
2889
2940
  }
2890
2941
  }
2942
+ function getProcessMemRss(pid) {
2943
+ if (process.platform === "win32") {
2944
+ try {
2945
+ const out = execSync(`tasklist /fi "PID eq ${pid}" /fo csv /nh 2>nul`, { encoding: "utf-8", timeout: 3e3 });
2946
+ for (const raw of out.split("\n")) {
2947
+ if (!raw.trim()) continue;
2948
+ const parts = raw.split('","').map((p) => p.replace(/^"|"$/g, ""));
2949
+ if (parseInt(parts[1] ?? "", 10) !== pid) continue;
2950
+ const memKb = parseInt((parts[4] ?? "").replace(/[^\d]/g, ""), 10);
2951
+ return isNaN(memKb) ? null : memKb;
2952
+ }
2953
+ return null;
2954
+ } catch {
2955
+ return null;
2956
+ }
2957
+ }
2958
+ if (process.platform === "darwin" || process.platform === "freebsd" || process.platform === "openbsd" || process.platform === "netbsd") {
2959
+ try {
2960
+ const out = execSync(`ps -o rss= -p ${pid} 2>/dev/null`, { encoding: "utf-8", timeout: 3e3 }).trim();
2961
+ const kb = parseInt(out, 10);
2962
+ return isNaN(kb) ? null : kb;
2963
+ } catch {
2964
+ return null;
2965
+ }
2966
+ }
2967
+ try {
2968
+ const status = readFileSync(`/proc/${pid}/status`, "utf-8");
2969
+ const m = status.match(/VmRSS:\s+(\d+)/);
2970
+ return m ? parseInt(m[1], 10) : null;
2971
+ } catch {
2972
+ return null;
2973
+ }
2974
+ }
2891
2975
  function checkPort(port, host = "127.0.0.1", timeout = 1e3) {
2892
- const tryHost = (h) => new Promise((resolve13) => {
2976
+ const tryHost = (h) => new Promise((resolve12) => {
2893
2977
  const socket = new net.Socket();
2894
2978
  let settled = false;
2895
2979
  const finish = (result) => {
2896
2980
  if (settled) return;
2897
2981
  settled = true;
2898
2982
  socket.destroy();
2899
- resolve13(result);
2983
+ resolve12(result);
2900
2984
  };
2901
2985
  socket.setTimeout(timeout);
2902
2986
  socket.once("connect", () => finish(true));
@@ -3089,6 +3173,44 @@ function formatProcessState(state) {
3089
3173
  }
3090
3174
 
3091
3175
  // src/commands/tracker.ts
3176
+ function extractFlagValue(args2, long, short) {
3177
+ for (let i = 0; i < args2.length; i++) {
3178
+ const a = args2[i];
3179
+ if (a === long || short && a === short) return args2[i + 1];
3180
+ if (a.startsWith(`${long}=`)) return a.slice(long.length + 1);
3181
+ if (short && a.startsWith(`${short}=`)) return a.slice(short.length + 1);
3182
+ }
3183
+ return void 0;
3184
+ }
3185
+ function getGroups() {
3186
+ const tracked = readTracked();
3187
+ const groups = /* @__PURE__ */ new Set();
3188
+ for (const t of tracked) if (t.group) groups.add(t.group);
3189
+ return [...groups].sort();
3190
+ }
3191
+ function resolveTargets(args2) {
3192
+ const groupFlag = extractFlagValue(args2, "--group", "-g");
3193
+ const all = args2.includes("--all") || args2.includes("-a");
3194
+ const positionals = args2.filter((a) => !a.startsWith("-") && !a.includes("="));
3195
+ const groups = getGroups();
3196
+ if (groupFlag) return { kind: "group", group: groupFlag };
3197
+ if (positionals.length === 1 && positionals[0] === "all") return { kind: "all" };
3198
+ if (all) return { kind: "all" };
3199
+ if (positionals.length === 1 && groups.includes(positionals[0])) {
3200
+ return { kind: "group", group: positionals[0] };
3201
+ }
3202
+ if (positionals.length === 1) return { kind: "single", value: positionals[0] };
3203
+ if (positionals.length > 1) return { kind: "names", values: positionals };
3204
+ return { kind: "none" };
3205
+ }
3206
+ function setGroup(name, group) {
3207
+ const tracked = readTracked();
3208
+ const idx = tracked.findIndex((t) => t.name === name);
3209
+ if (idx === -1) return false;
3210
+ tracked[idx] = { ...tracked[idx], group };
3211
+ saveTracked(tracked);
3212
+ return true;
3213
+ }
3092
3214
  var RESTART_CAUSE = "fennec:restart";
3093
3215
  function getFennecDir() {
3094
3216
  return process.env.FENNEC_DATA_DIR ? resolve2(process.env.FENNEC_DATA_DIR) : resolve2(homedir(), ".fennec");
@@ -3253,7 +3375,7 @@ function respawnTracked(proc, cause) {
3253
3375
  if (cause) annotateRestart(logFilePath, cause);
3254
3376
  if (isProcessRunning(proc.pid)) {
3255
3377
  try {
3256
- process.kill(proc.pid, "SIGTERM");
3378
+ killTree(proc.pid, "SIGTERM");
3257
3379
  } catch {
3258
3380
  }
3259
3381
  }
@@ -3442,7 +3564,7 @@ function supervisorStop() {
3442
3564
  return;
3443
3565
  }
3444
3566
  try {
3445
- process.kill(pid, "SIGTERM");
3567
+ killTree(pid, "SIGTERM");
3446
3568
  console.error(`
3447
3569
  ${pc4.green("\u2713")} ${pc4.bold("Supervisor stopped")} ${pc4.dim(`(PID ${pid})`)}
3448
3570
  `);
@@ -3822,6 +3944,7 @@ async function psCommand(args2) {
3822
3944
  const systemFlag = args2.includes("--system") || args2.includes("-a") || args2.includes("--all");
3823
3945
  const jsonFlag = args2.includes("--json");
3824
3946
  const nameFilter = args2.includes("--name") ? args2[args2.indexOf("--name") + 1] : void 0;
3947
+ const groupFilter = extractFlagValue(args2, "--group", "-g");
3825
3948
  const sortBy = args2.includes("--sort") ? args2[args2.indexOf("--sort") + 1] : "name";
3826
3949
  if (jsonFlag) {
3827
3950
  await psJson();
@@ -3874,10 +3997,11 @@ async function psCommand(args2) {
3874
3997
  }
3875
3998
  return;
3876
3999
  }
3877
- const tracked = readTracked();
4000
+ const trackedAll = readTracked();
4001
+ const tracked = groupFilter ? trackedAll.filter((t) => t.group === groupFilter) : trackedAll;
3878
4002
  if (tracked.length === 0) {
3879
4003
  console.error(`
3880
- ${pc6.dim("No tracked processes.")}`);
4004
+ ${pc6.dim(groupFilter ? `No tracked processes in group "${groupFilter}".` : "No tracked processes.")}`);
3881
4005
  console.error(` ${pc6.dim("Start an app with:")} ${pc6.cyan("fennec start <command> --name <name>")}
3882
4006
  `);
3883
4007
  return;
@@ -3889,10 +4013,18 @@ async function psCommand(args2) {
3889
4013
  const s = v;
3890
4014
  return s === "running" ? pc6.green("\u25CF running") : pc6.red("\u25CB stopped");
3891
4015
  } },
4016
+ { key: "group", label: "Group", format: (v) => {
4017
+ const g = String(v);
4018
+ return g === "-" ? pc6.dim("-") : pc6.cyan(g);
4019
+ } },
3892
4020
  { key: "port", label: "Port", format: (v) => {
3893
4021
  const p = v;
3894
4022
  return p ? pc6.yellow(`:${p}`) : pc6.dim("-");
3895
4023
  } },
4024
+ { key: "mem", label: "MEM", align: "right", format: (v) => {
4025
+ const kb = v;
4026
+ return kb && kb > 0 ? pc6.dim(`${(kb / 1024).toFixed(0)}MB`) : pc6.dim("-");
4027
+ } },
3896
4028
  { key: "command", label: "Command", format: (v) => {
3897
4029
  const c = String(v);
3898
4030
  return c.length > 50 ? c.slice(0, 50) + "\u2026" : c;
@@ -3902,11 +4034,13 @@ async function psCommand(args2) {
3902
4034
  const rows = tracked.map((t) => {
3903
4035
  const running = isTrackedRunning(t);
3904
4036
  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 };
4037
+ const memKb = running ? getProcessMemRss(t.pid) ?? null : null;
4038
+ return { name: t.name, pid: running ? String(t.pid) : pc6.dim(String(t.pid)), status: running ? "running" : "stopped", group: t.group ?? "-", port: t.port ?? null, mem: memKb, command: t.command, uptime };
3906
4039
  });
3907
4040
  const runningCount = tracked.filter((t) => isTrackedRunning(t)).length;
4041
+ const scope = groupFilter ? ` in group ${pc6.cyan(groupFilter)}` : "";
3908
4042
  console.error(`
3909
- ${symbols.fox} ${pc6.bold("Fennec Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running)`)}
4043
+ ${symbols.fox} ${pc6.bold("Fennec Apps")} ${pc6.dim(`(${runningCount}/${tracked.length} running${scope})`)}
3910
4044
  `);
3911
4045
  console.error(renderTable(columns, rows));
3912
4046
  console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec start <command> --name <name> --port <port>")} ${pc6.dim("to add more apps.")}`);
@@ -3914,6 +4048,7 @@ async function psCommand(args2) {
3914
4048
  console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec stop <name>")} ${pc6.dim("to pause an app.")}`);
3915
4049
  console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec spawn <name>")} ${pc6.dim("to resume a paused app.")}`);
3916
4050
  console.error(` ${pc6.dim("Use")} ${pc6.cyan("fennec kill <name>")} ${pc6.dim("to permanently remove an app.")}`);
4051
+ console.error(` ${pc6.dim("Filter by group with:")} ${pc6.cyan("fennec ps --group <group>")}`);
3917
4052
  console.error();
3918
4053
  }
3919
4054
  async function psJson() {
@@ -3927,6 +4062,11 @@ async function psJson() {
3927
4062
  port: t.port ?? null,
3928
4063
  command: t.command,
3929
4064
  cwd: t.cwd ?? null,
4065
+ group: t.group ?? null,
4066
+ memMB: running ? (() => {
4067
+ const kb = getProcessMemRss(t.pid);
4068
+ return kb ? Math.round(kb / 1024) : null;
4069
+ })() : null,
3930
4070
  startedAt: t.startedAt,
3931
4071
  uptime: running ? Math.floor((Date.now() - new Date(t.startedAt).getTime()) / 1e3) : null
3932
4072
  };
@@ -3994,11 +4134,11 @@ ${renderTable(columns, rows, { compact: true })}`;
3994
4134
  process.stdout.write("\x1B[J");
3995
4135
  console.error(render());
3996
4136
  }, 3e3);
3997
- await new Promise((resolve13) => {
4137
+ await new Promise((resolve12) => {
3998
4138
  process.once("SIGINT", () => {
3999
4139
  clearInterval(interval);
4000
4140
  process.stdout.write("\x1B[J");
4001
- resolve13();
4141
+ resolve12();
4002
4142
  });
4003
4143
  });
4004
4144
  }
@@ -4075,6 +4215,7 @@ async function runCommand(args2) {
4075
4215
  const cwd = cwdIndex !== -1 ? resolve4(args2[cwdIndex + 1]) : process.cwd();
4076
4216
  const restartFlag = args2.includes("--restart");
4077
4217
  const jsonlFlag = args2.includes("--jsonl");
4218
+ const group = extractFlagValue(args2, "--group", "-g");
4078
4219
  const stopFlags = [nameIndex, portIndex, cwdIndex].filter((i) => i !== -1);
4079
4220
  const cmdEnd = Math.min(...stopFlags, Infinity);
4080
4221
  const cmdParts = args2.slice(0, cmdEnd);
@@ -4114,6 +4255,7 @@ async function runCommand(args2) {
4114
4255
  if (port) console.error(` ${renderKV("Port", String(port))}`);
4115
4256
  if (cwd) console.error(` ${renderKV("Directory", cwd)}`);
4116
4257
  if (restartFlag) console.error(` ${renderKV("Auto-restart", pc7.green("enabled"))}`);
4258
+ if (group) console.error(` ${renderKV("Group", group)}`);
4117
4259
  console.error(` ${divider()}`);
4118
4260
  try {
4119
4261
  const startEnv = {};
@@ -4132,7 +4274,8 @@ async function runCommand(args2) {
4132
4274
  env: startEnv,
4133
4275
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4134
4276
  autoRestart: restartFlag,
4135
- logMode: jsonlFlag ? "jsonl" : "text"
4277
+ logMode: jsonlFlag ? "jsonl" : "text",
4278
+ group
4136
4279
  });
4137
4280
  console.error(` ${pc7.green("\u2713")} ${pc7.bold(appName)} ${pc7.dim(`started (PID: ${pid})`)}`);
4138
4281
  const spinner = createSpinner(port ? `Waiting for ${appName} on port ${port}...` : `Confirming ${appName} is running...`);
@@ -4213,25 +4356,44 @@ async function resurrectTracked() {
4213
4356
  // src/commands/kill.ts
4214
4357
  import pc8 from "picocolors";
4215
4358
  async function killCommand(args2) {
4216
- const rawTarget = args2[0];
4217
4359
  const signalIndex = args2.indexOf("--signal");
4218
4360
  const signalRaw = signalIndex !== -1 ? args2[signalIndex + 1] : "SIGTERM";
4219
4361
  const signal = signalRaw ?? "SIGTERM";
4220
4362
  const force = args2.includes("-y") || args2.includes("--yes");
4221
- if (rawTarget === "all" || args2.includes("--all") || args2.includes("-a")) {
4222
- await killAll(signal, force);
4363
+ const target = resolveTargets(args2);
4364
+ if (target.kind === "single") {
4365
+ await killOne(target.value, signal, force, false);
4223
4366
  return;
4224
4367
  }
4225
- if (!rawTarget) {
4226
- console.error(renderError("Missing target", "Usage: fennec kill <pid|name|all> [--signal SIGTERM|SIGKILL|SIGINT]"));
4227
- process.exit(1);
4368
+ if (target.kind === "names") {
4369
+ for (const name of target.values) {
4370
+ await killOne(name, signal, force, true);
4371
+ }
4372
+ return;
4373
+ }
4374
+ if (target.kind === "group") {
4375
+ await killGroup(target.group, signal, force);
4376
+ return;
4377
+ }
4378
+ if (target.kind === "all") {
4379
+ await killAll(signal, force);
4380
+ return;
4228
4381
  }
4382
+ console.error(renderError("Missing target", "Usage: fennec kill <pid|name|all> [--signal SIGTERM|SIGKILL|SIGINT] [--group <group>] [-y]"));
4383
+ process.exit(1);
4384
+ }
4385
+ async function killOne(rawTarget, signal, force, multi) {
4229
4386
  let targetPid;
4230
4387
  let displayName;
4231
4388
  const pid = parseInt(rawTarget, 10);
4232
4389
  if (!isNaN(pid) && String(pid) === rawTarget) {
4233
4390
  if (!isProcessRunning(pid)) {
4234
- console.error(renderError("Process not found", `No process with PID ${pid} is running`));
4391
+ const msg = `No process with PID ${pid} is running`;
4392
+ if (multi) {
4393
+ console.error(renderError("Process not found", msg));
4394
+ return;
4395
+ }
4396
+ console.error(renderError("Process not found", msg));
4235
4397
  process.exit(1);
4236
4398
  }
4237
4399
  targetPid = pid;
@@ -4250,8 +4412,13 @@ async function killCommand(args2) {
4250
4412
  targetPid = trackedMatch.pid;
4251
4413
  displayName = `${trackedMatch.name} (PID ${targetPid})`;
4252
4414
  } 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.`));
4415
+ const msg = `No tracked process named "${rawTarget}".
4416
+ Use ${pc8.cyan("fennec kill <pid>")} to kill a system process by its PID.`;
4417
+ if (multi) {
4418
+ console.error(renderError("Not tracked", msg));
4419
+ return;
4420
+ }
4421
+ console.error(renderError("Not tracked", msg));
4255
4422
  process.exit(1);
4256
4423
  }
4257
4424
  }
@@ -4261,7 +4428,7 @@ Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4261
4428
  return;
4262
4429
  }
4263
4430
  const spinner = createSpinner(`Sending ${signal} to ${displayName}...`);
4264
- const success = killProcess(targetPid, signal);
4431
+ const success = killTree(targetPid, signal);
4265
4432
  if (success) {
4266
4433
  await new Promise((r) => setTimeout(r, 200));
4267
4434
  const stillRunning = isProcessRunning(targetPid);
@@ -4270,12 +4437,12 @@ Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4270
4437
  const forceKill = force || await confirmPrompt(`Send ${pc8.red("SIGKILL")} to force stop?`, true);
4271
4438
  if (forceKill) {
4272
4439
  const forceSpinner = createSpinner(`Sending SIGKILL to ${displayName}...`);
4273
- killProcess(targetPid, "SIGKILL");
4440
+ killTree(targetPid, "SIGKILL");
4274
4441
  await new Promise((r) => setTimeout(r, 300));
4275
4442
  isProcessRunning(targetPid) ? forceSpinner.fail(`${displayName} could not be stopped (permission denied?)`) : forceSpinner.succeed(`${displayName} force stopped`);
4276
4443
  } else {
4277
4444
  console.error(` ${pc8.dim("Retrying with SIGTERM...")}`);
4278
- killProcess(targetPid, "SIGTERM");
4445
+ killTree(targetPid, "SIGTERM");
4279
4446
  console.error(` ${pc8.yellow("\u26A0")} ${displayName} ${pc8.dim("may still be running")}`);
4280
4447
  }
4281
4448
  } else {
@@ -4287,6 +4454,43 @@ Use ${pc8.cyan(`fennec kill <pid>`)} to kill a system process by its PID.`));
4287
4454
  console.error(renderError("Permission denied", "Try running with sudo or use a different signal."));
4288
4455
  }
4289
4456
  }
4457
+ async function killGroup(group, signal, force) {
4458
+ const tracked = readTracked();
4459
+ const inGroup = tracked.filter((t) => t.group === group);
4460
+ if (inGroup.length === 0) {
4461
+ console.error(renderError("Empty group", `No tracked entries in group "${group}".
4462
+ Known groups: ${pc8.cyan(getGroups().join(", ") || "(none)")}`));
4463
+ process.exit(1);
4464
+ }
4465
+ const running = inGroup.filter((t) => isTrackedRunning(t));
4466
+ const stopped = inGroup.filter((t) => !isTrackedRunning(t));
4467
+ console.error(`
4468
+ ${pc8.bold(`Kill group ${pc8.cyan(group)}: ${running.length} running + remove ${stopped.length} stopped?`)}`);
4469
+ console.error(` ${pc8.dim("Permanently removes only apps in this group \u2014 other groups are untouched.")}
4470
+ `);
4471
+ const confirmed = force || await confirmPrompt(`${pc8.red("Are you sure?")} ${pc8.dim("This cannot be undone")}`, false);
4472
+ if (!confirmed) {
4473
+ console.error(` ${pc8.dim("Cancelled.")}`);
4474
+ return;
4475
+ }
4476
+ const spinner = createSpinner(`Killing ${running.length} + removing ${stopped.length} tracked app(s) in ${group}...`);
4477
+ let killed = 0, failed = 0;
4478
+ for (const t of running) {
4479
+ if (killTree(t.pid, signal)) {
4480
+ killed++;
4481
+ } else {
4482
+ failed++;
4483
+ }
4484
+ removeTrackedByPid(t.pid);
4485
+ }
4486
+ for (const t of stopped) {
4487
+ removeTrackedByPid(t.pid);
4488
+ }
4489
+ await new Promise((r) => setTimeout(r, 300));
4490
+ const total = running.length + stopped.length;
4491
+ total > 0 ? spinner.succeed(`${killed} killed, ${stopped.length} stopped entries removed from group ${group}`) : spinner.fail("Failed to kill apps");
4492
+ if (failed > 0) console.error(` ${pc8.yellow(`${failed} running app(s) could not be killed`)}`);
4493
+ }
4290
4494
  async function killAll(signal, force) {
4291
4495
  const tracked = readTracked();
4292
4496
  if (tracked.length === 0) {
@@ -4307,7 +4511,7 @@ async function killAll(signal, force) {
4307
4511
  const spinner = createSpinner(`Killing ${running.length} + removing ${stopped.length} tracked app(s)...`);
4308
4512
  let killed = 0, failed = 0;
4309
4513
  for (const t of running) {
4310
- if (killProcess(t.pid, signal)) {
4514
+ if (killTree(t.pid, signal)) {
4311
4515
  killed++;
4312
4516
  } else {
4313
4517
  failed++;
@@ -4319,34 +4523,53 @@ async function killAll(signal, force) {
4319
4523
  }
4320
4524
  await new Promise((r) => setTimeout(r, 300));
4321
4525
  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");
4526
+ total > 0 ? spinner.succeed(`${killed} killed, ${stopped.length} stopped entries removed`) : spinner.fail("Failed to kill apps");
4323
4527
  if (failed > 0) console.error(` ${pc8.yellow(`${failed} running app(s) could not be killed`)}`);
4324
4528
  }
4325
4529
 
4326
4530
  // src/commands/stop.ts
4327
4531
  import pc9 from "picocolors";
4328
4532
  async function stopCommand(args2) {
4329
- const stopAll = args2.includes("--all") || args2.includes("-a");
4330
- if (stopAll) {
4533
+ const target = resolveTargets(args2);
4534
+ if (target.kind === "group") {
4535
+ await stopAllTracked(args2, target.group);
4536
+ return;
4537
+ }
4538
+ if (target.kind === "all") {
4331
4539
  await stopAllTracked(args2);
4332
4540
  return;
4333
4541
  }
4334
- const rawTarget = args2[0];
4335
- if (!rawTarget) {
4336
- console.error(renderError("Missing name", "Usage: fennec stop <name|--all>"));
4337
- process.exit(1);
4542
+ if (target.kind === "single") {
4543
+ await stopOne(target.value, args2);
4544
+ return;
4338
4545
  }
4546
+ if (target.kind === "names") {
4547
+ for (const name of target.values) {
4548
+ await stopOne(name, args2, true);
4549
+ }
4550
+ return;
4551
+ }
4552
+ console.error(renderError("Missing name", "Usage: fennec stop <name|--all> [--group <group>] [-y]"));
4553
+ process.exit(1);
4554
+ }
4555
+ async function stopOne(rawTarget, args2, multi = false) {
4339
4556
  const tracked = readTracked();
4340
4557
  const trackedMatch = tracked.find((t) => t.name === rawTarget);
4341
4558
  if (!trackedMatch) {
4342
- console.error(renderError("Not found", `No tracked process named "${rawTarget}". Use ${pc9.cyan("fennec kill <pid>")} to kill a system process.`));
4559
+ const msg = `No tracked process named "${rawTarget}". Use ${pc9.cyan("fennec kill <pid>")} to kill a system process.`;
4560
+ if (multi) {
4561
+ console.error(renderError("Not found", msg));
4562
+ return;
4563
+ }
4564
+ console.error(renderError("Not found", msg));
4343
4565
  process.exit(1);
4344
4566
  }
4345
4567
  if (!isTrackedRunning(trackedMatch)) {
4346
4568
  console.error(`
4347
4569
  ${pc9.yellow("\u26A0")} ${pc9.bold(rawTarget)} ${pc9.dim("is already stopped")}`);
4348
4570
  console.error();
4349
- process.exit(0);
4571
+ if (!multi) process.exit(0);
4572
+ return;
4350
4573
  }
4351
4574
  const displayName = `${trackedMatch.name} (PID ${trackedMatch.pid})`;
4352
4575
  const force = args2.includes("-y") || args2.includes("--yes");
@@ -4358,17 +4581,18 @@ async function stopCommand(args2) {
4358
4581
  setAutoRestart(trackedMatch.name, false);
4359
4582
  await stopSingleProcess(trackedMatch.pid, trackedMatch.name);
4360
4583
  }
4361
- async function stopAllTracked(args2) {
4584
+ async function stopAllTracked(args2, group) {
4362
4585
  const tracked = readTracked();
4363
- const running = tracked.filter((t) => isTrackedRunning(t));
4586
+ const running = tracked.filter((t) => isTrackedRunning(t) && (!group || t.group === group));
4364
4587
  if (running.length === 0) {
4365
4588
  console.error(`
4366
- ${pc9.dim("No running tracked processes to stop.")}
4589
+ ${pc9.dim(group ? `No running tracked processes in group "${group}".` : "No running tracked processes to stop.")}
4367
4590
  `);
4368
4591
  return;
4369
4592
  }
4593
+ const scope = group ? ` group ${pc9.cyan(group)}` : "";
4370
4594
  console.error(`
4371
- ${pc9.yellow("\u26A0")} ${pc9.bold(`Stop all ${running.length} running process(es)?`)} ${pc9.dim("(They can be re-spawned later)")}
4595
+ ${pc9.yellow("\u26A0")} ${pc9.bold(`Stop all ${running.length} running process(es)${scope}?`)} ${pc9.dim("(They can be re-spawned later)")}
4372
4596
  `);
4373
4597
  for (const t of running) {
4374
4598
  console.error(` ${pc9.green("\u25CF")} ${pc9.bold(t.name)} ${pc9.dim(`(PID ${t.pid})`)}`);
@@ -4386,7 +4610,7 @@ async function stopAllTracked(args2) {
4386
4610
  let failed = 0;
4387
4611
  for (const t of running) {
4388
4612
  setAutoRestart(t.name, false);
4389
- if (killProcess(t.pid, "SIGTERM")) {
4613
+ if (killTree(t.pid, "SIGTERM")) {
4390
4614
  stopped++;
4391
4615
  } else {
4392
4616
  failed++;
@@ -4407,7 +4631,7 @@ async function stopAllTracked(args2) {
4407
4631
  async function stopSingleProcess(pid, name) {
4408
4632
  const displayName = `${name} (PID ${pid})`;
4409
4633
  const spinner = createSpinner(`Stopping ${displayName}...`);
4410
- const success = killProcess(pid, "SIGTERM");
4634
+ const success = killTree(pid, "SIGTERM");
4411
4635
  if (success) {
4412
4636
  await new Promise((r) => setTimeout(r, 200));
4413
4637
  const stillRunning = isProcessRunning(pid);
@@ -4416,7 +4640,7 @@ async function stopSingleProcess(pid, name) {
4416
4640
  const forceKill = await confirmPrompt(`Send ${pc9.red("SIGKILL")} to force stop?`, true);
4417
4641
  if (forceKill) {
4418
4642
  const forceSpinner = createSpinner(`Sending SIGKILL to ${displayName}...`);
4419
- killProcess(pid, "SIGKILL");
4643
+ killTree(pid, "SIGKILL");
4420
4644
  await new Promise((r) => setTimeout(r, 300));
4421
4645
  isProcessRunning(pid) ? forceSpinner.fail(`${displayName} could not be stopped (permission denied?)`) : forceSpinner.succeed(`${displayName} force stopped`);
4422
4646
  } else {
@@ -4439,20 +4663,45 @@ import { mkdirSync as mkdirSync5 } from "fs";
4439
4663
  import { resolve as resolve5 } from "path";
4440
4664
  import { homedir as homedir3 } from "os";
4441
4665
  async function spawnCommand(args2) {
4442
- const spawnAll = args2.includes("--all") || args2.includes("-a");
4443
- const name = args2[0];
4444
- if (spawnAll) {
4445
- await spawnAllStopped();
4666
+ const target = resolveTargets(args2);
4667
+ if (target.kind === "single") {
4668
+ await spawnOne(target.value, false);
4446
4669
  return;
4447
4670
  }
4448
- if (!name) {
4449
- await spawnList();
4671
+ if (target.kind === "names") {
4672
+ for (const name of target.values) {
4673
+ await spawnOne(name, true);
4674
+ }
4675
+ return;
4676
+ }
4677
+ if (target.kind === "group") {
4678
+ const group = target.group;
4679
+ const tracked = readTracked();
4680
+ const inGroup = tracked.filter((t) => t.group === group && !isTrackedRunning(t) && t.command);
4681
+ if (inGroup.length === 0) {
4682
+ console.error(renderError("Empty group", `No stopped entries with a saved command in group "${group}".
4683
+ Known groups: ${pc10.cyan(getGroups().join(", ") || "(none)")}`));
4684
+ process.exit(1);
4685
+ }
4686
+ await spawnAllStopped(inGroup);
4687
+ return;
4688
+ }
4689
+ if (target.kind === "all") {
4690
+ await spawnAllStopped(readTracked().filter((t) => !isTrackedRunning(t) && t.command));
4450
4691
  return;
4451
4692
  }
4693
+ await spawnList();
4694
+ }
4695
+ async function spawnOne(name, multi) {
4452
4696
  const tracked = readTracked();
4453
4697
  const match = tracked.find((t) => t.name === name);
4454
4698
  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.`));
4699
+ 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.`;
4700
+ if (multi) {
4701
+ console.error(renderError("Not found", msg));
4702
+ return;
4703
+ }
4704
+ console.error(renderError("Not found", msg));
4456
4705
  process.exit(1);
4457
4706
  }
4458
4707
  if (isTrackedRunning(match)) {
@@ -4460,17 +4709,22 @@ async function spawnCommand(args2) {
4460
4709
  ${pc10.yellow("\u26A0")} ${pc10.bold(name)} ${pc10.dim("is already running (PID:")} ${match.pid}${pc10.dim(")")}`);
4461
4710
  console.error(` ${pc10.dim("Use")} ${pc10.cyan(`fennec stop ${name}`)} ${pc10.dim("to stop it first.")}`);
4462
4711
  console.error();
4463
- process.exit(0);
4712
+ if (!multi) process.exit(0);
4713
+ return;
4464
4714
  }
4465
4715
  if (!match.command) {
4466
- console.error(renderError("No command saved", `"${name}" has no saved command and cannot be re-spawned.`));
4716
+ const msg = `"${name}" has no saved command and cannot be re-spawned.`;
4717
+ if (multi) {
4718
+ console.error(renderError("No command saved", msg));
4719
+ return;
4720
+ }
4721
+ console.error(renderError("No command saved", msg));
4467
4722
  process.exit(1);
4468
4723
  }
4469
4724
  await respawnProcess(match);
4470
4725
  }
4471
- async function spawnAllStopped() {
4472
- const tracked = readTracked();
4473
- const toSpawn = tracked.filter((t) => !isTrackedRunning(t) && t.command);
4726
+ async function spawnAllStopped(procs) {
4727
+ const toSpawn = procs;
4474
4728
  if (toSpawn.length === 0) {
4475
4729
  console.error(`
4476
4730
  ${pc10.dim("No stopped processes with saved commands to spawn.")}
@@ -4607,32 +4861,46 @@ async function respawnProcess(proc) {
4607
4861
 
4608
4862
  // src/commands/restart.ts
4609
4863
  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
4864
  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);
4865
+ const force = args2.includes("-y") || args2.includes("--yes");
4866
+ const target = resolveTargets(args2);
4867
+ if (target.kind === "group") {
4868
+ await restartGroup(target.group, force);
4869
+ return;
4870
+ }
4871
+ if (target.kind === "names") {
4872
+ for (const n of target.values) await restartOne(n, force, true);
4873
+ return;
4874
+ }
4875
+ if (target.kind === "single") {
4876
+ await restartOne(target.value, force, false);
4877
+ return;
4618
4878
  }
4879
+ console.error(renderError("Missing process name/pid", "Usage: fennec restart <name|pid> [-y] [--group <group>]"));
4880
+ process.exit(1);
4881
+ }
4882
+ async function restartOne(raw, force, multi) {
4619
4883
  const tracked = readTracked();
4620
4884
  const pidNum = parseInt(raw, 10);
4621
4885
  const trackedEntry = tracked.find((t) => t.name === raw) ?? (!isNaN(pidNum) && String(pidNum) === raw ? tracked.find((t) => t.pid === pidNum) : void 0);
4622
4886
  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).`));
4887
+ const msg = `No tracked process named or with PID "${raw}".
4888
+ fennec restart only re-spawns Fennec-tracked apps (from their saved config).`;
4889
+ if (multi) {
4890
+ console.error(renderError("Not tracked", msg));
4891
+ return;
4892
+ }
4893
+ console.error(renderError("Not tracked", msg));
4625
4894
  process.exit(1);
4626
4895
  }
4627
4896
  const targetPid = trackedEntry.pid;
4628
- const force = args2.includes("-y") || args2.includes("--yes");
4629
4897
  const confirmed = force || await confirmPrompt(`Restart ${pc11.bold(`${trackedEntry.name} (PID ${targetPid})`)}?`, false);
4630
4898
  if (!confirmed) {
4631
4899
  console.error(` ${pc11.dim("Cancelled")}`);
4632
4900
  return;
4633
4901
  }
4634
4902
  const spinner = createSpinner(`Stopping ${trackedEntry.name} (PID ${targetPid})...`);
4635
- const killed = killProcess(targetPid, "SIGTERM");
4903
+ const killed = killTree(targetPid, "SIGTERM");
4636
4904
  if (!killed) {
4637
4905
  spinner.fail(`Failed to stop PID ${targetPid}`);
4638
4906
  return;
@@ -4640,16 +4908,14 @@ fennec restart only re-spawns Fennec-tracked apps (from their saved config).`));
4640
4908
  await new Promise((r) => setTimeout(r, 1e3));
4641
4909
  if (isProcessRunning(targetPid)) {
4642
4910
  spinner.warn("Process didn't stop, sending SIGKILL...");
4643
- killProcess(targetPid, "SIGKILL");
4911
+ killTree(targetPid, "SIGKILL");
4644
4912
  await new Promise((r) => setTimeout(r, 500));
4645
4913
  }
4646
4914
  spinner.succeed(`${trackedEntry.name} stopped`);
4647
4915
  const respawnSpinner = createSpinner(`Re-spawning ${trackedEntry.name}...`);
4648
4916
  try {
4649
4917
  const cmdParts = resolveArgs(trackedEntry);
4650
- const logDir = resolve6(homedir4(), ".fennec", "logs");
4651
- mkdirSync6(logDir, { recursive: true });
4652
- const logFilePath = resolve6(logDir, `${trackedEntry.name}.log`);
4918
+ const logFilePath = logFilePathFor(trackedEntry.name);
4653
4919
  const child = spawnDaemon({ cmdParts, name: trackedEntry.name, cwd: trackedEntry.cwd, logFilePath, env: buildSpawnEnv(trackedEntry.env), logMode: trackedEntry.logMode });
4654
4920
  removeTrackedByPid(targetPid);
4655
4921
  addTracked({
@@ -4670,6 +4936,45 @@ fennec restart only re-spawns Fennec-tracked apps (from their saved config).`));
4670
4936
  console.error(` ${pc11.dim("Config preserved. Re-run manually:")} ${renderCommand(trackedEntry.command)}`);
4671
4937
  }
4672
4938
  }
4939
+ async function restartGroup(group, force) {
4940
+ const tracked = readTracked();
4941
+ const inGroup = tracked.filter((t) => t.group === group);
4942
+ if (inGroup.length === 0) {
4943
+ console.error(renderError("Empty group", `No tracked entries in group "${group}".
4944
+ Known groups: ${pc11.cyan(getGroups().join(", ") || "(none)")}`));
4945
+ process.exit(1);
4946
+ }
4947
+ const confirmed = force || await confirmPrompt(`Restart ${pc11.bold(`${inGroup.length}`)} process(es) in group ${pc11.cyan(group)}?`, false);
4948
+ if (!confirmed) {
4949
+ console.error(` ${pc11.dim("Cancelled")}`);
4950
+ return;
4951
+ }
4952
+ for (const trackedEntry of inGroup) {
4953
+ const spinner = createSpinner(`Restarting ${trackedEntry.name}...`);
4954
+ try {
4955
+ const cmdParts = resolveArgs(trackedEntry);
4956
+ const logFilePath = logFilePathFor(trackedEntry.name);
4957
+ const child = spawnDaemon({ cmdParts, name: trackedEntry.name, cwd: trackedEntry.cwd, logFilePath, env: buildSpawnEnv(trackedEntry.env), logMode: trackedEntry.logMode });
4958
+ removeTrackedByPid(trackedEntry.pid);
4959
+ addTracked({
4960
+ name: trackedEntry.name,
4961
+ pid: child.pid ?? 0,
4962
+ command: trackedEntry.command,
4963
+ args: cmdParts,
4964
+ port: trackedEntry.port,
4965
+ cwd: trackedEntry.cwd,
4966
+ env: trackedEntry.env,
4967
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4968
+ autoRestart: trackedEntry.autoRestart,
4969
+ logMode: trackedEntry.logMode
4970
+ });
4971
+ spinner.succeed(`${trackedEntry.name} restarted (PID: ${child.pid})`);
4972
+ } catch (error) {
4973
+ spinner.fail(`Failed to re-spawn ${trackedEntry.name}: ${String(error)}`);
4974
+ console.error(` ${pc11.dim("Config preserved. Re-run manually:")} ${renderCommand(trackedEntry.command)}`);
4975
+ }
4976
+ }
4977
+ }
4673
4978
 
4674
4979
  // src/commands/adopt.ts
4675
4980
  import pc12 from "picocolors";
@@ -4904,7 +5209,7 @@ async function inspectCommand(args2) {
4904
5209
  // src/commands/dev.ts
4905
5210
  import pc14 from "picocolors";
4906
5211
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
4907
- import { resolve as resolve7, dirname as dirname5 } from "path";
5212
+ import { resolve as resolve6, dirname as dirname5 } from "path";
4908
5213
  import "os";
4909
5214
  import yaml from "js-yaml";
4910
5215
  function loadConfig(path) {
@@ -4923,7 +5228,7 @@ function loadConfig(path) {
4923
5228
  }
4924
5229
  function resolveCwd(baseDir, cwd) {
4925
5230
  if (!cwd) return baseDir;
4926
- return resolve7(baseDir, cwd);
5231
+ return resolve6(baseDir, cwd);
4927
5232
  }
4928
5233
  async function waitForPort(port, timeoutMs = 3e4) {
4929
5234
  const deadline = Date.now() + timeoutMs;
@@ -4972,7 +5277,7 @@ async function startApp(app, baseDir) {
4972
5277
  const wasRunning = !!(existing && isTrackedRunning(existing));
4973
5278
  if (wasRunning) {
4974
5279
  try {
4975
- process.kill(existing.pid, "SIGTERM");
5280
+ killTree(existing.pid, "SIGTERM");
4976
5281
  } catch {
4977
5282
  }
4978
5283
  }
@@ -5014,7 +5319,7 @@ async function startApp(app, baseDir) {
5014
5319
  async function devCommand(args2) {
5015
5320
  const sub = args2[0] ?? "up";
5016
5321
  const configIndex = args2.indexOf("--config");
5017
- const configPath = configIndex !== -1 ? resolve7(args2[configIndex + 1]) : resolve7(process.cwd(), "fennec.config.yaml");
5322
+ const configPath = configIndex !== -1 ? resolve6(args2[configIndex + 1]) : resolve6(process.cwd(), "fennec.config.yaml");
5018
5323
  if (sub === "down") {
5019
5324
  return devDown();
5020
5325
  }
@@ -5086,7 +5391,7 @@ function devDown() {
5086
5391
  ${symbols.fox} ${pc14.bold("fennec dev down")} ${pc14.dim(`\u2014 stopping ${tracked.length} app(s)`)}`);
5087
5392
  for (const t of tracked) {
5088
5393
  try {
5089
- if (isTrackedRunning(t)) process.kill(t.pid, "SIGTERM");
5394
+ if (isTrackedRunning(t)) killTree(t.pid, "SIGTERM");
5090
5395
  } catch {
5091
5396
  }
5092
5397
  t.autoRestart = false;
@@ -5384,10 +5689,10 @@ async function followLog(logFilePath, opts) {
5384
5689
  closeSync3(fd);
5385
5690
  }
5386
5691
  }, 400);
5387
- await new Promise((resolve13) => {
5692
+ await new Promise((resolve12) => {
5388
5693
  const stop = () => {
5389
5694
  clearInterval(timer);
5390
- resolve13();
5695
+ resolve12();
5391
5696
  };
5392
5697
  process.once("SIGINT", stop);
5393
5698
  process.once("SIGTERM", stop);
@@ -5451,37 +5756,237 @@ async function attachCommand(args2) {
5451
5756
  }
5452
5757
  }
5453
5758
 
5454
- // src/commands/sessions.ts
5455
- import { SessionStore } from "@plumpslabs/fennec-core";
5759
+ // src/commands/store.ts
5760
+ import { StoreManager, redactSession } from "@plumpslabs/fennec-core";
5761
+ import "@plumpslabs/fennec-core";
5456
5762
  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) {
5763
+ var KINDS = ["session", "process", "workflow", "plugin", "config", "export"];
5764
+ function formatBytes(n) {
5765
+ if (n < 1024) return `${n} B`;
5766
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
5767
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
5768
+ }
5769
+ function formatAge(ms) {
5770
+ const s = Math.floor(ms / 1e3);
5771
+ if (s < 60) return `${s}s`;
5772
+ const m = Math.floor(s / 60);
5773
+ if (m < 60) return `${m}m`;
5774
+ const h = Math.floor(m / 60);
5775
+ if (h < 24) return `${h}h`;
5776
+ return `${Math.floor(h / 24)}d`;
5777
+ }
5778
+ async function storeCommand(args2) {
5779
+ const local = args2.includes("--local");
5780
+ const showSecrets = args2.includes("--show-secrets");
5781
+ const yes = args2.includes("-y") || args2.includes("--yes");
5782
+ const positional = [];
5783
+ for (const a of args2) {
5784
+ if (a.startsWith("--") || a === "-y") continue;
5785
+ positional.push(a);
5786
+ }
5787
+ const kind = positional[0];
5788
+ const action = positional[1];
5789
+ const name = positional[2];
5790
+ const mgr = new StoreManager(local);
5791
+ mgr.ensure();
5792
+ if (!kind) {
5793
+ const scan = mgr.scan();
5461
5794
  console.error(`
5462
- ${pc17.dim("No saved sessions found.")}
5795
+ ${symbols.fox} ${pc17.bold("Fennec Store")} ${pc17.dim(local ? "(local ./.fennec)" : "(global)")}
5796
+ `);
5797
+ const columns = [
5798
+ { key: "kind", label: "Kind", format: (v) => pc17.bold(String(v)) },
5799
+ { key: "count", label: "Items", format: (v) => pc17.cyan(String(v)) },
5800
+ { key: "size", label: "Size", format: (v) => pc17.dim(formatBytes(Number(v))) },
5801
+ { key: "oldest", label: "Oldest", format: (v) => pc17.dim(formatAge(Number(v))) }
5802
+ ];
5803
+ const rows = scan.map((e) => ({
5804
+ kind: e.kind,
5805
+ count: e.count,
5806
+ size: e.sizeBytes,
5807
+ oldest: e.oldestMs
5808
+ }));
5809
+ console.error(renderTable(columns, rows));
5810
+ console.error(` ${pc17.dim(mgr.base)}
5463
5811
  `);
5464
5812
  return;
5465
5813
  }
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(`
5814
+ if (!KINDS.includes(kind)) {
5815
+ console.error(renderError("Unknown kind", `Kind must be one of: ${KINDS.join(", ")}`));
5816
+ process.exit(1);
5817
+ }
5818
+ if (kind !== "session") {
5819
+ const entry = mgr.scan().find((e) => e.kind === kind);
5820
+ console.error(`
5821
+ ${pc17.bold(kind)} ${pc17.dim(`(${entry?.count ?? 0} item(s))`)}
5822
+ `);
5823
+ console.error(` ${pc17.dim(mgr.dirFor(kind))}
5824
+ `);
5825
+ 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).";
5826
+ console.error(` ${pc17.dim(tip)}
5827
+ `);
5828
+ return;
5829
+ }
5830
+ const store = mgr.sessionStore();
5831
+ if (!action || action === "ls" || action === "list") {
5832
+ const sessions = store.list();
5833
+ if (sessions.length === 0) {
5834
+ console.error(`
5835
+ ${pc17.dim(`No saved sessions found${local ? " in ./.fennec/sessions" : " in the global store"}.`)}
5836
+ `);
5837
+ return;
5838
+ }
5839
+ const columns = [
5840
+ { key: "name", label: "Name", format: (v) => pc17.bold(String(v)) },
5841
+ { key: "origin", label: "Origin" },
5842
+ { key: "savedAt", label: "Saved", format: (v) => pc17.dim(String(v)) }
5843
+ ];
5844
+ const rows = sessions.map((s) => ({
5845
+ name: s.name,
5846
+ origin: s.origin,
5847
+ savedAt: new Date(s.savedAt).toLocaleString()
5848
+ }));
5849
+ console.error(`
5473
5850
  ${symbols.fox} ${pc17.bold("Saved Sessions")}
5474
5851
  `);
5475
- console.error(renderTable(columns, rows));
5476
- console.error(` ${pc17.dim(`${sessions.length} session(s)`)}
5852
+ console.error(renderTable(columns, rows));
5853
+ console.error(` ${pc17.dim(`${sessions.length} session(s)`)}
5854
+ `);
5855
+ return;
5856
+ }
5857
+ if (action === "info") {
5858
+ if (!name) {
5859
+ console.error(renderError("Missing name", "Usage: fennec store session info <name> [--show-secrets]"));
5860
+ process.exit(1);
5861
+ }
5862
+ const session = store.load(name);
5863
+ if (!session) {
5864
+ console.error(renderError("Session not found", `No session named "${name}". Use 'fennec store session' to list.`));
5865
+ process.exit(1);
5866
+ }
5867
+ const shown = showSecrets ? session : redactSession(session);
5868
+ const cookieDomains = shown.cookies.map((c) => String(c.domain ?? c.name ?? "?")).filter((v, i, a) => a.indexOf(v) === i);
5869
+ let body = "";
5870
+ body += renderKV("Name", shown.name);
5871
+ body += renderKV("Origin", shown.origin);
5872
+ body += renderKV("Saved", new Date(shown.savedAt).toLocaleString());
5873
+ if (shown.metadata) {
5874
+ for (const [k, v] of Object.entries(shown.metadata)) body += renderKV(k, String(v));
5875
+ }
5876
+ body += renderKV("Cookies", `${shown.cookies.length} (domains: ${cookieDomains.join(", ") || "-"})`);
5877
+ body += renderKV("localStorage keys", String(Object.keys(shown.localStorage).length));
5878
+ body += renderKV("Values", showSecrets ? "shown" : pc17.dim("masked \u2014 use --show-secrets"));
5879
+ console.error(`
5880
+ ${pc17.bold(`Session: ${shown.name}`)}
5881
+ `);
5882
+ console.error(renderSection("Details", body));
5883
+ return;
5884
+ }
5885
+ if (action === "rm" || action === "remove" || action === "delete") {
5886
+ if (!name) {
5887
+ console.error(renderError("Missing name", "Usage: fennec store session rm <name>"));
5888
+ process.exit(1);
5889
+ }
5890
+ if (!store.load(name)) {
5891
+ console.error(renderError("Session not found", `No session named "${name}".`));
5892
+ process.exit(1);
5893
+ }
5894
+ const confirmed = yes || await confirmPrompt(`Delete session ${pc17.bold(name)}?`, false);
5895
+ if (!confirmed) {
5896
+ console.error(` ${pc17.dim("Cancelled")}`);
5897
+ return;
5898
+ }
5899
+ const ok = store.delete(name);
5900
+ console.error(ok ? ` ${pc17.green("\u2713")} ${pc17.bold(name)} deleted
5901
+ ` : ` ${pc17.red("\u2717")} failed to delete ${pc17.bold(name)}
5477
5902
  `);
5903
+ return;
5904
+ }
5905
+ console.error(renderError("Unknown action", "Actions: ls | info <name> | rm <name>"));
5906
+ process.exit(1);
5478
5907
  }
5479
5908
 
5480
- // src/commands/setup.ts
5909
+ // src/commands/doctor.ts
5910
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
5911
+ import { join } from "path";
5912
+ import { execSync as execSync3 } from "child_process";
5913
+ import { StoreManager as StoreManager2 } from "@plumpslabs/fennec-core";
5481
5914
  import pc18 from "picocolors";
5915
+ async function doctorCommand() {
5916
+ const mgr = new StoreManager2(false);
5917
+ const base = mgr.base;
5918
+ const problems = [];
5919
+ const notes = [];
5920
+ if (!mgr.permsSafe()) {
5921
+ problems.push(`Store dir is group/other readable: ${base}
5922
+ Fix: chmod 700 ${base}`);
5923
+ }
5924
+ if (StoreManager2.isSynced(base)) {
5925
+ problems.push(
5926
+ `Store lives under a synced directory: ${base}
5927
+ Auth cookies/tokens in sessions + process launch commands could leak across machines.
5928
+ Prefer a non-synced location (FENNEC_HOME=/non-synced/fennec) or use --local with care.`
5929
+ );
5930
+ }
5931
+ const scan = mgr.scan();
5932
+ const sess = scan.find((s) => s.kind === "session");
5933
+ if (sess && sess.count > 0) {
5934
+ notes.push(
5935
+ `${sess.count} auth session(s) with cookies/localStorage stored on disk.
5936
+ Values are masked by default in 'fennec store session info'; use --show-secrets to reveal.`
5937
+ );
5938
+ }
5939
+ const proc = scan.find((s) => s.kind === "process");
5940
+ if (proc && proc.count > 0) {
5941
+ const tracked = mgr.fileFor("process", "");
5942
+ const cmds = existsSync10(tracked) ? JSON.parse(readFileSync9(tracked, "utf-8")) : [];
5943
+ const secretCmds = cmds.filter(
5944
+ (c) => c.command && /(?:^|[\s;|&])([A-Za-z_][A-Za-z0-9_]*)=/.test(c.command)
5945
+ ).length;
5946
+ if (secretCmds > 0) {
5947
+ notes.push(
5948
+ `${secretCmds} tracked process launch command(s) may embed secrets (KEY=value).
5949
+ Stored in ${tracked} \u2014 keep the store out of synced/committed locations.`
5950
+ );
5951
+ }
5952
+ }
5953
+ const localFen = join(process.cwd(), ".fennec");
5954
+ if (existsSync10(localFen)) {
5955
+ let ignored = false;
5956
+ try {
5957
+ execSync3("git check-ignore -q .fennec", { cwd: process.cwd(), stdio: "ignore" });
5958
+ ignored = true;
5959
+ } catch (err) {
5960
+ ignored = err?.code === "ENOENT";
5961
+ }
5962
+ if (!ignored) {
5963
+ problems.push(
5964
+ `Local ./.fennec exists here and is NOT gitignored.
5965
+ It can hold sessions + tracked processes \u2014 add '.fennec/' to .gitignore or remove it.`
5966
+ );
5967
+ }
5968
+ }
5969
+ console.error(`
5970
+ ${pc18.bold("Fennec Doctor")}
5971
+ `);
5972
+ console.error(` ${pc18.dim("Store:")} ${base}
5973
+ `);
5974
+ if (problems.length === 0 && notes.length === 0) {
5975
+ console.error(` ${pc18.green("\u2713")} No issues found.
5976
+ `);
5977
+ return;
5978
+ }
5979
+ for (const p of problems) console.error(` ${pc18.red("\u2717")} ${p}
5980
+ `);
5981
+ for (const n of notes) console.error(` ${pc18.yellow("!")} ${n}
5982
+ `);
5983
+ }
5984
+
5985
+ // src/commands/setup.ts
5986
+ import pc19 from "picocolors";
5482
5987
  async function setupCommand() {
5483
5988
  console.error(`
5484
- ${symbols.fox} ${pc18.bold("Fennec Setup")}
5989
+ ${symbols.fox} ${pc19.bold("Fennec Setup")}
5485
5990
  `);
5486
5991
  const mcpClient = await selectPrompt("Which MCP client are you using?", [
5487
5992
  { value: "claude", label: "Claude Desktop", description: "Anthropic's AI desktop app" },
@@ -5490,12 +5995,12 @@ async function setupCommand() {
5490
5995
  { value: "other", label: "Other MCP client", description: "Any MCP-compatible client" }
5491
5996
  ]);
5492
5997
  if (!mcpClient) {
5493
- console.error(` ${pc18.dim("Setup cancelled.")}
5998
+ console.error(` ${pc19.dim("Setup cancelled.")}
5494
5999
  `);
5495
6000
  return;
5496
6001
  }
5497
6002
  console.error(`
5498
- ${pc18.green("\u2713")} Selected: ${pc18.bold(mcpClient)}
6003
+ ${pc19.green("\u2713")} Selected: ${pc19.bold(mcpClient)}
5499
6004
  `);
5500
6005
  const configSnippet = `{
5501
6006
  "mcpServers": {
@@ -5505,46 +6010,46 @@ async function setupCommand() {
5505
6010
  }
5506
6011
  }
5507
6012
  }`;
5508
- console.error(` ${pc18.bold("Add this to your MCP client config:")}
6013
+ console.error(` ${pc19.bold("Add this to your MCP client config:")}
5509
6014
  `);
5510
- console.error(` ${pc18.dim("```")}`);
6015
+ console.error(` ${pc19.dim("```")}`);
5511
6016
  console.error(configSnippet.split("\n").map((l) => ` ${l}`).join("\n"));
5512
- console.error(` ${pc18.dim("```")}
6017
+ console.error(` ${pc19.dim("```")}
5513
6018
  `);
5514
- console.error(` ${renderSuccess("Setup complete!")} ${pc18.dim("Run")} ${renderCommand("fennec start")} ${pc18.dim("to begin.")}
6019
+ console.error(` ${renderSuccess("Setup complete!")} ${pc19.dim("Run")} ${renderCommand("fennec start")} ${pc19.dim("to begin.")}
5515
6020
  `);
5516
6021
  }
5517
6022
 
5518
6023
  // 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";
6024
+ import { existsSync as existsSync11, writeFileSync as writeFileSync4 } from "fs";
6025
+ import { resolve as resolve7 } from "path";
6026
+ import { execSync as execSync4 } from "child_process";
6027
+ import pc20 from "picocolors";
5523
6028
  async function installBrowsersCommand() {
5524
6029
  console.error(`
5525
- ${pc19.bold("Installing Browser Engines")}
6030
+ ${pc20.bold("Installing Browser Engines")}
5526
6031
  `);
5527
6032
  const spinner = createSpinner("Installing Chromium...");
5528
6033
  try {
5529
- execSync3("npx playwright install chromium", { stdio: "pipe", timeout: 12e4 });
6034
+ execSync4("npx playwright install chromium", { stdio: "pipe", timeout: 12e4 });
5530
6035
  spinner.succeed("Chromium installed successfully");
5531
6036
  } catch {
5532
6037
  spinner.fail("Failed to install Chromium");
5533
- console.error(` ${pc19.yellow("\u2192")} Try running: ${renderCommand("npx playwright install chromium")}`);
6038
+ console.error(` ${pc20.yellow("\u2192")} Try running: ${renderCommand("npx playwright install chromium")}`);
5534
6039
  }
5535
6040
  console.error(`
5536
- ${pc19.green("\u2713")} Browser installation complete.
6041
+ ${pc20.green("\u2713")} Browser installation complete.
5537
6042
  `);
5538
6043
  }
5539
6044
  async function initCommand() {
5540
6045
  console.error(`
5541
- ${pc19.bold("Initialize Fennec Configuration")}
6046
+ ${pc20.bold("Initialize Fennec Configuration")}
5542
6047
  `);
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);
6048
+ const configFile = resolve7("./fennec.config.yaml");
6049
+ if (existsSync11(configFile)) {
6050
+ const overwrite = await confirmPrompt(`${pc20.yellow("fennec.config.yaml")} already exists. Overwrite?`, false);
5546
6051
  if (!overwrite) {
5547
- console.error(` ${pc19.dim("Cancelled.")}
6052
+ console.error(` ${pc20.dim("Cancelled.")}
5548
6053
  `);
5549
6054
  return;
5550
6055
  }
@@ -5624,44 +6129,44 @@ logging:
5624
6129
  file: null
5625
6130
  `;
5626
6131
  writeFileSync4(configFile, config, "utf-8");
5627
- spinner.succeed(`Configuration written to ${pc19.bold(configFile)}`);
6132
+ spinner.succeed(`Configuration written to ${pc20.bold(configFile)}`);
5628
6133
  console.error(`
5629
- ${pc19.dim("Edit the file to customize Fennec behavior.")}
6134
+ ${pc20.dim("Edit the file to customize Fennec behavior.")}
5630
6135
  `);
5631
6136
  }
5632
6137
 
5633
6138
  // 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";
6139
+ import { existsSync as existsSync12, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
6140
+ import { execSync as execSync5 } from "child_process";
6141
+ import { resolve as resolve8 } from "path";
6142
+ import { homedir as homedir5 } from "os";
6143
+ import pc21 from "picocolors";
5639
6144
  async function healthCommand() {
5640
6145
  printBanner();
5641
- console.error(` ${pc20.bold("Fennec Health Check")}
6146
+ console.error(` ${pc21.bold("Fennec Health Check")}
5642
6147
  `);
5643
6148
  let adbStatus = "unknown";
5644
6149
  try {
5645
- const result = execSync4("adb --version", {
6150
+ const result = execSync5("adb --version", {
5646
6151
  encoding: "utf-8",
5647
6152
  timeout: 3e3,
5648
6153
  stdio: ["ignore", "pipe", "pipe"]
5649
6154
  });
5650
6155
  const version = result.split("\n")[0]?.trim() ?? "installed";
5651
- adbStatus = pc20.green(version);
6156
+ adbStatus = pc21.green(version);
5652
6157
  } catch {
5653
- adbStatus = pc20.dim("not found") + " (optional)";
6158
+ adbStatus = pc21.dim("not found") + " (optional)";
5654
6159
  }
5655
6160
  const tracked = readTracked();
5656
6161
  const runningCount = tracked.filter((t) => isProcessRunning(t.pid)).length;
5657
- const logDir = resolve9(homedir6(), ".fennec", "logs");
6162
+ const logDir = resolve8(homedir5(), ".fennec", "logs");
5658
6163
  let logSize = "0 B";
5659
6164
  try {
5660
- if (existsSync11(logDir)) {
6165
+ if (existsSync12(logDir)) {
5661
6166
  const files = readdirSync2(logDir);
5662
6167
  let totalBytes = 0;
5663
6168
  for (const f of files) {
5664
- const fStat = statSync3(resolve9(logDir, f));
6169
+ const fStat = statSync3(resolve8(logDir, f));
5665
6170
  totalBytes += fStat.size;
5666
6171
  }
5667
6172
  logSize = totalBytes > 1024 * 1024 ? `${(totalBytes / (1024 * 1024)).toFixed(1)} MB` : `${(totalBytes / 1024).toFixed(1)} KB`;
@@ -5674,7 +6179,7 @@ async function healthCommand() {
5674
6179
  memoryInfo = `${(mem.rss / (1024 * 1024)).toFixed(0)} MB RSS / ${(mem.heapUsed / (1024 * 1024)).toFixed(0)} MB heap`;
5675
6180
  } catch {
5676
6181
  }
5677
- console.error(` ${symbols.fox} ${pc20.bold("System")}
6182
+ console.error(` ${symbols.fox} ${pc21.bold("System")}
5678
6183
  `);
5679
6184
  console.error(` ${renderKV("Node.js", process.version)}`);
5680
6185
  console.error(` ${renderKV("Platform", `${process.platform} ${process.arch}`)}`);
@@ -5682,7 +6187,7 @@ async function healthCommand() {
5682
6187
  console.error(` ${renderKV("ADB", adbStatus)}`);
5683
6188
  console.error(` ${renderKV("PID", String(process.pid))}`);
5684
6189
  console.error();
5685
- console.error(` ${symbols.fox} ${pc20.bold("Processes")}
6190
+ console.error(` ${symbols.fox} ${pc21.bold("Processes")}
5686
6191
  `);
5687
6192
  console.error(` ${renderKV("Tracked", String(tracked.length))}`);
5688
6193
  console.error(` ${renderKV("Running", String(runningCount))}`);
@@ -5690,56 +6195,56 @@ async function healthCommand() {
5690
6195
  console.error();
5691
6196
  const allHealthy = runningCount === tracked.length || tracked.length === 0;
5692
6197
  if (allHealthy) {
5693
- console.error(` ${pc20.green("\u2713")} ${pc20.bold("All systems healthy")}
6198
+ console.error(` ${pc21.green("\u2713")} ${pc21.bold("All systems healthy")}
5694
6199
  `);
5695
6200
  } else {
5696
6201
  const stopped = tracked.length - runningCount;
5697
- console.error(` ${pc20.yellow("\u26A0")} ${pc20.bold(`${stopped} process(es) stopped`)} ${pc20.dim("fennec ps")}
6202
+ console.error(` ${pc21.yellow("\u26A0")} ${pc21.bold(`${stopped} process(es) stopped`)} ${pc21.dim("fennec ps")}
5698
6203
  `);
5699
6204
  }
5700
6205
  }
5701
6206
 
5702
6207
  // src/commands/cleanup.ts
5703
- import pc21 from "picocolors";
6208
+ import pc22 from "picocolors";
5704
6209
  async function cleanupCommand() {
5705
6210
  const tracked = readTracked();
5706
6211
  if (tracked.length === 0) {
5707
6212
  console.error(`
5708
- ${pc21.dim("No tracked processes to clean up.")}
6213
+ ${pc22.dim("No tracked processes to clean up.")}
5709
6214
  `);
5710
6215
  return;
5711
6216
  }
5712
6217
  const toRemove = tracked.filter((t) => !isProcessRunning(t.pid) && !t.command);
5713
6218
  if (toRemove.length === 0) {
5714
6219
  console.error(`
5715
- ${pc21.green("\u2713")} ${pc21.bold("All clean")} ${pc21.dim("\u2014 no dead entries without saved commands.")}
6220
+ ${pc22.green("\u2713")} ${pc22.bold("All clean")} ${pc22.dim("\u2014 no dead entries without saved commands.")}
5716
6221
  `);
5717
6222
  return;
5718
6223
  }
5719
6224
  console.error(`
5720
- ${pc21.yellow("\u26A0")} ${pc21.bold(`Found ${toRemove.length} dead entr${toRemove.length > 1 ? "ies" : "y"} without saved commands`)}
6225
+ ${pc22.yellow("\u26A0")} ${pc22.bold(`Found ${toRemove.length} dead entr${toRemove.length > 1 ? "ies" : "y"} without saved commands`)}
5721
6226
  `);
5722
6227
  for (const entry of toRemove) {
5723
- console.error(` ${pc21.dim("\xB7")} ${pc21.bold(entry.name)} ${pc21.dim(`(PID ${entry.pid})`)}`);
6228
+ console.error(` ${pc22.dim("\xB7")} ${pc22.bold(entry.name)} ${pc22.dim(`(PID ${entry.pid})`)}`);
5724
6229
  }
5725
6230
  console.error();
5726
6231
  const confirmed = await confirmPrompt(`Remove ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}?`, false);
5727
6232
  if (!confirmed) {
5728
- console.error(` ${pc21.dim("Cancelled")}
6233
+ console.error(` ${pc22.dim("Cancelled")}
5729
6234
  `);
5730
6235
  return;
5731
6236
  }
5732
6237
  const remaining = tracked.filter((t) => !toRemove.includes(t));
5733
6238
  saveTracked(remaining);
5734
6239
  console.error(`
5735
- ${pc21.green("\u2713")} ${pc21.bold(`Removed ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}`)} ${pc21.dim(`(${remaining.length} remaining)`)}
6240
+ ${pc22.green("\u2713")} ${pc22.bold(`Removed ${toRemove.length} entr${toRemove.length > 1 ? "ies" : "y"}`)} ${pc22.dim(`(${remaining.length} remaining)`)}
5736
6241
  `);
5737
6242
  }
5738
6243
 
5739
6244
  // src/commands/info.ts
5740
- import pc22 from "picocolors";
5741
- import { resolve as resolve10 } from "path";
5742
- import { homedir as homedir7 } from "os";
6245
+ import pc23 from "picocolors";
6246
+ import { resolve as resolve9 } from "path";
6247
+ import { homedir as homedir6 } from "os";
5743
6248
  async function infoCommand(args2) {
5744
6249
  const name = args2[0];
5745
6250
  if (!name) {
@@ -5753,34 +6258,34 @@ async function infoCommand(args2) {
5753
6258
  process.exit(1);
5754
6259
  }
5755
6260
  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");
6261
+ const statusIcon = running ? pc23.green("\u25CF") : pc23.red("\u25CB");
6262
+ const statusText = running ? pc23.green("running") : pc23.red("stopped");
5758
6263
  const uptime = running ? formatUptime(Math.floor((Date.now() - new Date(match.startedAt).getTime()) / 1e3)) : "-";
5759
- const logPath = resolve10(homedir7(), ".fennec", "logs", `${match.name}.log`);
6264
+ const logPath = resolve9(homedir6(), ".fennec", "logs", `${match.name}.log`);
5760
6265
  console.error(`
5761
- ${symbols.fox} ${pc22.bold(match.name)} ${pc22.dim("\u2014 Process Info")}
6266
+ ${symbols.fox} ${pc23.bold(match.name)} ${pc23.dim("\u2014 Process Info")}
5762
6267
  `);
5763
- console.error(` ${renderKVColor("Name", match.name, pc22.bold)}`);
6268
+ console.error(` ${renderKVColor("Name", match.name, pc23.bold)}`);
5764
6269
  console.error(` ${renderKVColor("Status", `${statusIcon} ${statusText}`)}`);
5765
6270
  console.error(` ${renderKVColor("PID", String(match.pid))}`);
5766
6271
  console.error(` ${renderKVColor("Port", match.port ? String(match.port) : "-")}`);
5767
- console.error(` ${renderKVColor("Command", match.command || pc22.dim("(none)"))}`);
6272
+ console.error(` ${renderKVColor("Command", match.command || pc23.dim("(none)"))}`);
5768
6273
  if (match.cwd) console.error(` ${renderKVColor("CWD", match.cwd)}`);
5769
6274
  console.error(` ${renderKVColor("Started", new Date(match.startedAt).toLocaleString())}`);
5770
6275
  console.error(` ${renderKVColor("Uptime", uptime)}`);
5771
6276
  console.error(` ${renderKVColor("Log Path", logPath)}`);
5772
6277
  if (!running && match.command) {
5773
6278
  console.error(`
5774
- ${pc22.dim("Re-spawn with:")} ${pc22.cyan(`fennec spawn ${match.name}`)}`);
6279
+ ${pc23.dim("Re-spawn with:")} ${pc23.cyan(`fennec spawn ${match.name}`)}`);
5775
6280
  }
5776
6281
  console.error();
5777
6282
  }
5778
6283
 
5779
6284
  // 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";
6285
+ import pc24 from "picocolors";
6286
+ import { existsSync as existsSync13, renameSync as renameSync2 } from "fs";
6287
+ import { resolve as resolve10 } from "path";
6288
+ import { homedir as homedir7 } from "os";
5784
6289
  async function renameCommand(args2) {
5785
6290
  const [oldName, newName] = args2;
5786
6291
  if (!oldName || !newName) {
@@ -5789,7 +6294,7 @@ async function renameCommand(args2) {
5789
6294
  }
5790
6295
  if (oldName === newName) {
5791
6296
  console.error(`
5792
- ${pc23.yellow("\u26A0")} ${pc23.dim("Old and new names are the same.")}
6297
+ ${pc24.yellow("\u26A0")} ${pc24.dim("Old and new names are the same.")}
5793
6298
  `);
5794
6299
  process.exit(0);
5795
6300
  }
@@ -5804,25 +6309,25 @@ async function renameCommand(args2) {
5804
6309
  process.exit(1);
5805
6310
  }
5806
6311
  console.error(`
5807
- ${symbols.fox} ${pc23.bold("Rename Process")}
6312
+ ${symbols.fox} ${pc24.bold("Rename Process")}
5808
6313
  `);
5809
6314
  console.error(` ${renderKVColor("From", oldName)}`);
5810
6315
  console.error(` ${renderKVColor("To", newName)}`);
5811
6316
  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.")}`);
6317
+ console.error(` ${pc24.yellow("\u26A0")} ${pc24.dim("Process is still running \u2014 log entries will go to the old file until stopped.")}`);
5813
6318
  }
5814
6319
  console.error();
5815
- const confirmed = await confirmPrompt(`Rename ${pc23.bold(oldName)} ${pc23.dim("\u2192")} ${pc23.bold(newName)}?`, true);
6320
+ const confirmed = await confirmPrompt(`Rename ${pc24.bold(oldName)} ${pc24.dim("\u2192")} ${pc24.bold(newName)}?`, true);
5816
6321
  if (!confirmed) {
5817
- console.error(` ${pc23.dim("Cancelled")}
6322
+ console.error(` ${pc24.dim("Cancelled")}
5818
6323
  `);
5819
6324
  return;
5820
6325
  }
5821
6326
  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)) {
6327
+ const logDir = resolve10(homedir7(), ".fennec", "logs");
6328
+ const oldLog = resolve10(logDir, `${oldName}.log`);
6329
+ const newLog = resolve10(logDir, `${newName}.log`);
6330
+ if (existsSync13(oldLog) && !existsSync13(newLog)) {
5826
6331
  try {
5827
6332
  renameSync2(oldLog, newLog);
5828
6333
  } catch {
@@ -5830,14 +6335,14 @@ async function renameCommand(args2) {
5830
6335
  }
5831
6336
  match.name = newName;
5832
6337
  saveTracked(tracked);
5833
- spinner.succeed(`${pc23.bold(oldName)} ${pc23.dim("\u2192")} ${pc23.bold(newName)}`);
6338
+ spinner.succeed(`${pc24.bold(oldName)} ${pc24.dim("\u2192")} ${pc24.bold(newName)}`);
5834
6339
  console.error();
5835
6340
  }
5836
6341
 
5837
6342
  // 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";
6343
+ import pc25 from "picocolors";
6344
+ import { existsSync as existsSync14, writeFileSync as writeFileSync5, readFileSync as readFileSync10 } from "fs";
6345
+ import { resolve as resolve11 } from "path";
5841
6346
  async function exportCommand(args2) {
5842
6347
  const tracked = readTracked();
5843
6348
  const fileIndex = args2.indexOf("--file");
@@ -5848,16 +6353,16 @@ async function exportCommand(args2) {
5848
6353
  }
5849
6354
  if (tracked.length === 0) {
5850
6355
  console.error(`
5851
- ${pc24.dim("No tracked processes to export.")}
6356
+ ${pc25.dim("No tracked processes to export.")}
5852
6357
  `);
5853
6358
  return;
5854
6359
  }
5855
6360
  const json = JSON.stringify(tracked, null, 2);
5856
6361
  if (filePath) {
5857
6362
  try {
5858
- writeFileSync5(resolve12(filePath), json, "utf-8");
6363
+ writeFileSync5(resolve11(filePath), json, "utf-8");
5859
6364
  console.error(`
5860
- ${pc24.green("\u2713")} ${pc24.bold(`Exported ${tracked.length} process(es)`)} ${pc24.dim(`\u2192 ${filePath}`)}
6365
+ ${pc25.green("\u2713")} ${pc25.bold(`Exported ${tracked.length} process(es)`)} ${pc25.dim(`\u2192 ${filePath}`)}
5861
6366
  `);
5862
6367
  } catch (err) {
5863
6368
  console.error(renderError("Export failed", String(err)));
@@ -5873,14 +6378,14 @@ async function importCommand(args2) {
5873
6378
  console.error(renderError("Missing file", "Usage: fennec import <file>"));
5874
6379
  process.exit(1);
5875
6380
  }
5876
- const resolvedPath = resolve12(filePath);
5877
- if (!existsSync13(resolvedPath)) {
6381
+ const resolvedPath = resolve11(filePath);
6382
+ if (!existsSync14(resolvedPath)) {
5878
6383
  console.error(renderError("File not found", `"${filePath}" does not exist.`));
5879
6384
  process.exit(1);
5880
6385
  }
5881
6386
  let imported;
5882
6387
  try {
5883
- imported = JSON.parse(readFileSync9(resolvedPath, "utf-8"));
6388
+ imported = JSON.parse(readFileSync10(resolvedPath, "utf-8"));
5884
6389
  if (!Array.isArray(imported) || imported.length === 0) {
5885
6390
  console.error(renderError("Invalid file", "File must contain a non-empty JSON array of process objects."));
5886
6391
  process.exit(1);
@@ -5891,20 +6396,20 @@ async function importCommand(args2) {
5891
6396
  }
5892
6397
  const existing = readTracked();
5893
6398
  console.error(`
5894
- ${symbols.fox} ${pc24.bold("Import Processes")}
6399
+ ${symbols.fox} ${pc25.bold("Import Processes")}
5895
6400
  `);
5896
6401
  console.error(` ${renderKVColor("File", resolvedPath)}`);
5897
6402
  console.error(` ${renderKVColor("Importing", `${imported.length} process(es)`)}`);
5898
6403
  console.error(` ${renderKVColor("Existing", `${existing.length} process(es)`)}`);
5899
6404
  console.error(`
5900
- ${pc24.bold("Processes to import:")}`);
6405
+ ${pc25.bold("Processes to import:")}`);
5901
6406
  for (const p of imported) {
5902
- console.error(` ${pc24.green("+")} ${pc24.bold(p.name)} ${pc24.dim(`(${p.command})`)}`);
6407
+ console.error(` ${pc25.green("+")} ${pc25.bold(p.name)} ${pc25.dim(`(${p.command})`)}`);
5903
6408
  }
5904
6409
  console.error();
5905
6410
  const confirmed = await confirmPrompt("Merge into tracked.json? Existing processes with the same name will be overwritten.", true);
5906
6411
  if (!confirmed) {
5907
- console.error(` ${pc24.dim("Cancelled")}
6412
+ console.error(` ${pc25.dim("Cancelled")}
5908
6413
  `);
5909
6414
  return;
5910
6415
  }
@@ -5918,8 +6423,63 @@ async function importCommand(args2) {
5918
6423
  }
5919
6424
  }
5920
6425
  saveTracked(merged);
5921
- console.error(` ${pc24.green("\u2713")} ${pc24.bold(`Imported ${imported.length} process(es)`)} ${pc24.dim(`(${merged.length} total)`)}
6426
+ console.error(` ${pc25.green("\u2713")} ${pc25.bold(`Imported ${imported.length} process(es)`)} ${pc25.dim(`(${merged.length} total)`)}
6427
+ `);
6428
+ }
6429
+
6430
+ // src/commands/group.ts
6431
+ import pc26 from "picocolors";
6432
+ async function groupCommand(args2) {
6433
+ const positional = args2.filter((a) => !a.startsWith("-") && !a.includes("="));
6434
+ const unset = args2.includes("--unset");
6435
+ if (positional.length === 0) {
6436
+ const tracked = readTracked();
6437
+ if (tracked.length === 0) {
6438
+ console.error(`
6439
+ ${pc26.dim("No tracked processes.")}
6440
+ `);
6441
+ return;
6442
+ }
6443
+ const columns = [
6444
+ { key: "name", label: "App", format: (v) => pc26.bold(String(v)) },
6445
+ { key: "group", label: "Group", format: (v) => {
6446
+ const g = String(v);
6447
+ return g === "-" ? pc26.dim("-") : pc26.cyan(g);
6448
+ } },
6449
+ { key: "command", label: "Command", format: (v) => {
6450
+ const c = String(v);
6451
+ return c.length > 50 ? c.slice(0, 50) + "\u2026" : c;
6452
+ } }
6453
+ ];
6454
+ const rows = tracked.map((t) => ({ name: t.name, group: t.group ?? "-", command: t.command }));
6455
+ console.error(`
6456
+ ${pc26.bold("Fennec Apps by Group")} ${pc26.dim(`(${getGroups().length} group(s))`)}
5922
6457
  `);
6458
+ console.error(renderTable(columns, rows));
6459
+ console.error(` ${pc26.dim("Assign:")} ${pc26.cyan("fennec group <name> <group>")} ${pc26.dim("Clear:")} ${pc26.cyan("fennec group <name> --unset")}`);
6460
+ console.error();
6461
+ return;
6462
+ }
6463
+ const name = positional[0];
6464
+ const newGroup = positional[1];
6465
+ if (!newGroup && !unset) {
6466
+ console.error(renderError("Missing group", "Usage: fennec group <name> <group> (or: fennec group <name> --unset)"));
6467
+ process.exit(1);
6468
+ }
6469
+ const ok = setGroup(name, unset ? void 0 : newGroup);
6470
+ if (!ok) {
6471
+ console.error(renderError("Not found", `No tracked process named "${name}".`));
6472
+ process.exit(1);
6473
+ }
6474
+ if (unset) {
6475
+ console.error(`
6476
+ ${pc26.green("\u2713")} ${pc26.bold(name)} ${pc26.dim("removed from its group")}
6477
+ `);
6478
+ } else {
6479
+ console.error(`
6480
+ ${pc26.green("\u2713")} ${pc26.bold(name)} ${pc26.dim("assigned to group")} ${pc26.cyan(newGroup)}
6481
+ `);
6482
+ }
5923
6483
  }
5924
6484
 
5925
6485
  // src/index.ts
@@ -5977,6 +6537,8 @@ async function main() {
5977
6537
  await cleanupCommand();
5978
6538
  } else if (command === "rename") {
5979
6539
  await renameCommand(args);
6540
+ } else if (command === "group") {
6541
+ await groupCommand(args);
5980
6542
  } else if (command === "export") {
5981
6543
  await exportCommand(args);
5982
6544
  } else if (command === "import") {
@@ -5991,8 +6553,14 @@ async function main() {
5991
6553
  await attachPortCommand(args);
5992
6554
  } else if (command === "watch") {
5993
6555
  await watchCommand(args);
6556
+ } else if (command === "store") {
6557
+ printBanner();
6558
+ await storeCommand(args);
6559
+ } else if (command === "doctor") {
6560
+ printBanner();
6561
+ await doctorCommand();
5994
6562
  } else if (command === "sessions") {
5995
- await sessionsCommand();
6563
+ await storeCommand(["session"]);
5996
6564
  } else if (command === "setup") {
5997
6565
  await setupCommand();
5998
6566
  } else if (command === "install-browsers") {