supbuddy 3.0.5 → 3.0.7

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/bin.js CHANGED
@@ -24328,7 +24328,7 @@ var init_mcp2 = __esm({
24328
24328
  }).strict(),
24329
24329
  enable_mapping: external_exports.object({ id: ID }).strict(),
24330
24330
  disable_mapping: external_exports.object({ id: ID }).strict(),
24331
- register_project: external_exports.object({ root_path: external_exports.string().min(1), label: external_exports.string().optional(), auto_scan: external_exports.boolean().default(true), isolation: external_exports.enum(["host"]).optional() }).strict(),
24331
+ register_project: external_exports.object({ root_path: external_exports.string().min(1), label: external_exports.string().optional(), auto_scan: external_exports.boolean().default(true), isolation: external_exports.enum(["host", "thin"]).optional() }).strict(),
24332
24332
  update_project: external_exports.object({ id: ID, patch: external_exports.object({ name: external_exports.string().optional(), enabled: external_exports.boolean().optional(), domain: external_exports.string().regex(DOMAIN_REGEX).optional(), isolation: external_exports.enum(["host"]).optional() }).strict() }).strict(),
24333
24333
  // `supabase_path` is the project-relative directory that CONTAINS the
24334
24334
  // `supabase/` folder ("." = repo root, e.g. "apps/getnightowls"), matching
@@ -26364,29 +26364,74 @@ var init_lo_alias_manager = __esm({
26364
26364
  // src/run.ts
26365
26365
  var run_exports = {};
26366
26366
  __export(run_exports, {
26367
- runDev: () => runDev
26367
+ isBoundBannerLine: () => isBoundBannerLine,
26368
+ pipeFiltered: () => pipeFiltered,
26369
+ resolveDevPort: () => resolveDevPort,
26370
+ runDev: () => runDev,
26371
+ selectDevUrls: () => selectDevUrls
26368
26372
  });
26369
26373
  import { spawn as spawn4, execSync } from "child_process";
26370
26374
  import fs7 from "fs";
26371
26375
  import path9 from "path";
26372
- function readMetaLoopbackIp(startDir) {
26376
+ function readMeta(startDir) {
26373
26377
  let dir = startDir;
26374
26378
  for (; ; ) {
26375
26379
  const metaPath = path9.join(dir, ".supbuddy", "meta.json");
26376
26380
  if (fs7.existsSync(metaPath)) {
26377
26381
  try {
26378
- return JSON.parse(fs7.readFileSync(metaPath, "utf-8")).loopbackIp;
26382
+ return JSON.parse(fs7.readFileSync(metaPath, "utf-8"));
26379
26383
  } catch {
26380
- return void 0;
26384
+ return {};
26381
26385
  }
26382
26386
  }
26383
26387
  const parent = path9.dirname(dir);
26384
- if (parent === dir) return void 0;
26388
+ if (parent === dir) return {};
26385
26389
  dir = parent;
26386
26390
  }
26387
26391
  }
26388
- function resolveLoopbackIp() {
26389
- return process.env.SUPBUDDY_LOOPBACK_IP || readMetaLoopbackIp(process.cwd()) || null;
26392
+ function resolveLoopbackIp(meta) {
26393
+ return process.env.SUPBUDDY_LOOPBACK_IP || meta.loopbackIp || null;
26394
+ }
26395
+ function resolveDevPort(argv, framework) {
26396
+ for (let i = 0; i < argv.length; i++) {
26397
+ const tok = argv[i];
26398
+ if (tok === "--port" || tok === "-p") {
26399
+ const n = Number(argv[i + 1]);
26400
+ if (Number.isInteger(n)) return n;
26401
+ }
26402
+ const m = /^(?:--port|-p)=(\d+)$/.exec(tok);
26403
+ if (m) return Number(m[1]);
26404
+ }
26405
+ return framework === "next" ? 3e3 : framework === "vite" ? 5173 : void 0;
26406
+ }
26407
+ function selectDevUrls(urls, devPort) {
26408
+ if (!urls || urls.length === 0) return [];
26409
+ if (devPort != null) {
26410
+ const match = urls.find((u) => u.port === devPort);
26411
+ if (match) return [match];
26412
+ }
26413
+ return urls;
26414
+ }
26415
+ function isBoundBannerLine(line, ip) {
26416
+ const bare = line.replace(ANSI_RE, "").trim();
26417
+ const ipRe = ip.replace(/\./g, "\\.");
26418
+ return new RegExp(`^\\W*(Local|Network):\\s*https?://${ipRe}(:\\d+)?/?$`, "i").test(bare);
26419
+ }
26420
+ function pipeFiltered(src, dst, drop) {
26421
+ let buf = "";
26422
+ src.on("data", (chunk) => {
26423
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
26424
+ let nl;
26425
+ while ((nl = buf.indexOf("\n")) !== -1) {
26426
+ const line = buf.slice(0, nl);
26427
+ buf = buf.slice(nl + 1);
26428
+ if (!drop(line)) dst.write(line + "\n");
26429
+ }
26430
+ });
26431
+ src.on("end", () => {
26432
+ if (buf && !drop(buf)) dst.write(buf);
26433
+ buf = "";
26434
+ });
26390
26435
  }
26391
26436
  function aliasExists(ip) {
26392
26437
  try {
@@ -26416,7 +26461,8 @@ async function runDev(rawArgv, opts = {}) {
26416
26461
  console.error("Usage: supbuddy run [--print] -- <dev command> (e.g. supbuddy run -- next dev)");
26417
26462
  return 1;
26418
26463
  }
26419
- const ip = resolveLoopbackIp();
26464
+ const meta = readMeta(process.cwd());
26465
+ const ip = resolveLoopbackIp(meta);
26420
26466
  if (!ip) {
26421
26467
  console.error(
26422
26468
  "[supbuddy run] no loopback IP for this project. Enable thin isolation in Supbuddy, or set SUPBUDDY_LOOPBACK_IP=127.0.0.N."
@@ -26431,21 +26477,37 @@ async function runDev(rawArgv, opts = {}) {
26431
26477
  return 0;
26432
26478
  }
26433
26479
  ensureAlias(ip);
26434
- console.error(`[supbuddy run] ${framework} \u2192 ${ip}: ${argv.join(" ")}`);
26480
+ const urls = selectDevUrls(meta.urls, resolveDevPort(argv, framework));
26481
+ if (urls.length) {
26482
+ for (const u of urls) console.error(`[supbuddy] \u2192 ${u.url}`);
26483
+ } else {
26484
+ console.error(`[supbuddy] ${framework} \u2192 ${ip}`);
26485
+ }
26435
26486
  return await new Promise((resolve) => {
26436
- const child = spawn4(argv[0], argv.slice(1), { stdio: "inherit", env: process.env });
26487
+ const filterBanner = framework === "next" || framework === "vite";
26488
+ const child = filterBanner ? spawn4(argv[0], argv.slice(1), {
26489
+ stdio: ["inherit", "pipe", "pipe"],
26490
+ env: { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" }
26491
+ }) : spawn4(argv[0], argv.slice(1), { stdio: "inherit", env: process.env });
26492
+ if (filterBanner) {
26493
+ const drop = (line) => isBoundBannerLine(line, ip);
26494
+ if (child.stdout) pipeFiltered(child.stdout, process.stdout, drop);
26495
+ if (child.stderr) pipeFiltered(child.stderr, process.stderr, drop);
26496
+ }
26437
26497
  child.on("error", (e) => {
26438
26498
  console.error(`[supbuddy run] failed to start "${argv[0]}": ${e.message}`);
26439
26499
  resolve(1);
26440
26500
  });
26441
- child.on("exit", (code) => resolve(code ?? 0));
26501
+ child.on("close", (code) => resolve(code ?? 0));
26442
26502
  });
26443
26503
  }
26504
+ var ANSI_RE;
26444
26505
  var init_run = __esm({
26445
26506
  "src/run.ts"() {
26446
26507
  "use strict";
26447
26508
  init_dev_command();
26448
26509
  init_lo_alias_manager();
26510
+ ANSI_RE = /\x1b\[[0-9;]*m/g;
26449
26511
  }
26450
26512
  });
26451
26513
 
@@ -25250,7 +25250,7 @@ const MCP_TOOL_SCHEMAS = {
25250
25250
  }).strict(),
25251
25251
  enable_mapping: objectType({ id: ID }).strict(),
25252
25252
  disable_mapping: objectType({ id: ID }).strict(),
25253
- register_project: objectType({ root_path: stringType().min(1), label: stringType().optional(), auto_scan: booleanType().default(true), isolation: enumType(["host"]).optional() }).strict(),
25253
+ register_project: objectType({ root_path: stringType().min(1), label: stringType().optional(), auto_scan: booleanType().default(true), isolation: enumType(["host", "thin"]).optional() }).strict(),
25254
25254
  update_project: objectType({ id: ID, patch: objectType({ name: stringType().optional(), enabled: booleanType().optional(), domain: stringType().regex(DOMAIN_REGEX).optional(), isolation: enumType(["host"]).optional() }).strict() }).strict(),
25255
25255
  // `supabase_path` is the project-relative directory that CONTAINS the
25256
25256
  // `supabase/` folder ("." = repo root, e.g. "apps/getnightowls"), matching
@@ -25672,6 +25672,75 @@ function resolveWorkerPort(env = typeof process !== "undefined" ? define_process
25672
25672
  }
25673
25673
  return DEFAULT_WORKER_PORT;
25674
25674
  }
25675
+ const LOOPBACK_RANGE_START = 2;
25676
+ const LOOPBACK_RANGE_END = 254;
25677
+ const LOOPBACK_RE = /^127\.0\.0\.\d{1,3}$/;
25678
+ function assertLoopbackIp(ip) {
25679
+ if (!LOOPBACK_RE.test(ip)) throw new Error(`invalid loopback IP: ${ip}`);
25680
+ }
25681
+ function loopbackIp(n2) {
25682
+ return `127.0.0.${n2}`;
25683
+ }
25684
+ function allocateLoopbackIp(taken) {
25685
+ const used = new Set(taken);
25686
+ for (let n2 = LOOPBACK_RANGE_START; n2 <= LOOPBACK_RANGE_END; n2++) {
25687
+ const ip = loopbackIp(n2);
25688
+ if (!used.has(ip)) return ip;
25689
+ }
25690
+ return null;
25691
+ }
25692
+ function buildAddAliasCommand(ip) {
25693
+ assertLoopbackIp(ip);
25694
+ return `ifconfig lo0 alias ${ip} up`;
25695
+ }
25696
+ function parseLo0Aliases(ifconfigOutput) {
25697
+ const ips = [];
25698
+ for (const line of ifconfigOutput.split("\n")) {
25699
+ const m = line.match(/^\s*inet (\d+\.\d+\.\d+\.\d+)\b/);
25700
+ if (m) ips.push(m[1]);
25701
+ }
25702
+ return ips;
25703
+ }
25704
+ function diffAliasesToAdd(want, have) {
25705
+ const present = new Set(have);
25706
+ return want.filter((ip) => !present.has(ip));
25707
+ }
25708
+ function buildEnsureAliasesCommand(missing) {
25709
+ if (missing.length === 0) return null;
25710
+ return missing.map(buildAddAliasCommand).join(" && ");
25711
+ }
25712
+ function reservedLoopbackIps(projects, exceptProjectId) {
25713
+ return projects.filter((p2) => p2.id !== exceptProjectId && p2.loopbackIp).map((p2) => p2.loopbackIp);
25714
+ }
25715
+ function resolveLoopbackIp(project, projects) {
25716
+ if (project.loopbackIp) return project.loopbackIp;
25717
+ return allocateLoopbackIp(reservedLoopbackIps(projects, project.id));
25718
+ }
25719
+ function thinAliasIps(projects) {
25720
+ const ips = projects.filter((p2) => p2.isolation === "thin" && p2.loopbackIp).map((p2) => p2.loopbackIp);
25721
+ return Array.from(new Set(ips));
25722
+ }
25723
+ function appMappingUpstreamUpdates(mappings, projectId, loopbackIp2) {
25724
+ return mappings.filter((m) => m.projectId === projectId && !m.autoGenerated).map((m) => ({ id: m.id, upstreamHost: loopbackIp2 ?? void 0 }));
25725
+ }
25726
+ function reconcileMissingThinLoopbacks(projects, mappings) {
25727
+ const assigned = [];
25728
+ const taken = new Set(projects.filter((p2) => p2.loopbackIp).map((p2) => p2.loopbackIp));
25729
+ let nextProjects = projects;
25730
+ let nextMappings = mappings;
25731
+ for (const p2 of projects) {
25732
+ if (p2.isolation !== "thin" || p2.loopbackIp) continue;
25733
+ const ip = allocateLoopbackIp(taken);
25734
+ if (!ip) break;
25735
+ taken.add(ip);
25736
+ assigned.push({ projectId: p2.id, ip });
25737
+ nextProjects = nextProjects.map((x2) => x2.id === p2.id ? { ...x2, loopbackIp: ip } : x2);
25738
+ for (const u2 of appMappingUpstreamUpdates(nextMappings, p2.id, ip)) {
25739
+ nextMappings = nextMappings.map((m) => m.id === u2.id ? { ...m, upstreamHost: u2.upstreamHost } : m);
25740
+ }
25741
+ }
25742
+ return { projects: nextProjects, mappings: nextMappings, assigned };
25743
+ }
25675
25744
  var define_process_env_default$a = {};
25676
25745
  const defaultSettings = {
25677
25746
  autoStart: true,
@@ -25683,7 +25752,10 @@ const defaultSettings = {
25683
25752
  lanSharing: true,
25684
25753
  defaultTld: "test",
25685
25754
  dnsPort: 5353,
25686
- defaultIsolation: "host",
25755
+ // Thin by default: new projects get their own loopback IP so dev servers keep
25756
+ // canonical ports (:3000) without cross-project collisions. Registration still
25757
+ // falls back to host when it detects a Supabase stack the user runs themselves.
25758
+ defaultIsolation: "thin",
25687
25759
  defaultVmConfig: DEFAULT_VM_CONFIG,
25688
25760
  autoSubdomainMapping: true,
25689
25761
  bundledRuntimeTrust: false,
@@ -25780,12 +25852,15 @@ const useStore = create((set2, get2) => ({
25780
25852
  return get2().projects.find((p2) => p2.id === id);
25781
25853
  },
25782
25854
  addMapping: (projectId, domain, port) => {
25855
+ const project = projectId ? get2().getProject(projectId) : void 0;
25856
+ const upstreamHost = project?.isolation === "thin" && project.loopbackIp ? project.loopbackIp : void 0;
25783
25857
  const newMapping = {
25784
25858
  id: crypto.randomUUID(),
25785
25859
  projectId,
25786
25860
  domain,
25787
25861
  port,
25788
25862
  enabled: true,
25863
+ ...upstreamHost ? { upstreamHost } : {},
25789
25864
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
25790
25865
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
25791
25866
  };
@@ -26144,6 +26219,13 @@ async function loadState() {
26144
26219
  };
26145
26220
  });
26146
26221
  const loadedSettings = { ...defaultSettings, ...parsed.settings };
26222
+ if (!loadedSettings.thinDefaultMigrated) {
26223
+ if (loadedSettings.defaultIsolation === "host") {
26224
+ console.log("[loadState] Migrating defaultIsolation host → thin (new shipped default)");
26225
+ loadedSettings.defaultIsolation = "thin";
26226
+ }
26227
+ loadedSettings.thinDefaultMigrated = true;
26228
+ }
26147
26229
  const tld = loadedSettings.defaultTld || "test";
26148
26230
  const syncedMappings = mappings.map((m) => {
26149
26231
  const parts = m.domain.split(".");
@@ -26163,9 +26245,15 @@ async function loadState() {
26163
26245
  }
26164
26246
  return p2;
26165
26247
  });
26248
+ const recon = reconcileMissingThinLoopbacks(syncedProjects, syncedMappings);
26249
+ if (recon.assigned.length > 0) {
26250
+ console.log(
26251
+ `[loadState] Assigned loopback IPs to ${recon.assigned.length} thin project(s) missing one: ` + recon.assigned.map((a) => `${a.projectId}→${a.ip}`).join(", ")
26252
+ );
26253
+ }
26166
26254
  useStore.setState({
26167
- projects: syncedProjects,
26168
- mappings: syncedMappings,
26255
+ projects: recon.projects,
26256
+ mappings: recon.mappings,
26169
26257
  settings: loadedSettings
26170
26258
  });
26171
26259
  useStore.setState({
@@ -26185,6 +26273,9 @@ async function loadState() {
26185
26273
  projectContextSync: parsed.projectContextSync ?? {},
26186
26274
  userSkills: parsed.userSkills ?? {}
26187
26275
  });
26276
+ if (recon.assigned.length > 0) {
26277
+ await persistState().catch((e) => console.error("[loadState] persist after loopback repair failed:", e));
26278
+ }
26188
26279
  } catch (error) {
26189
26280
  console.error("Failed to load state:", error);
26190
26281
  }
@@ -33320,12 +33411,29 @@ async function detectUnmanagedSupabaseStacks(knownProjectIds, run2 = defaultRunn
33320
33411
  );
33321
33412
  return groupUnmanaged(out, known);
33322
33413
  }
33414
+ async function hasIndependentHostSupabase(rootPath, searchPaths, run2 = defaultRunner$1) {
33415
+ let supabaseProjectId;
33416
+ try {
33417
+ const cfg = await findSupabaseConfig(rootPath, { supabaseSearchPaths: searchPaths });
33418
+ if (!cfg) return false;
33419
+ supabaseProjectId = parseSupabaseProjectId(cfg.content);
33420
+ } catch {
33421
+ return false;
33422
+ }
33423
+ if (!supabaseProjectId) return false;
33424
+ if (!/^[A-Za-z0-9._-]+$/.test(supabaseProjectId)) return false;
33425
+ const out = await run2(
33426
+ `docker ps --filter "label=com.docker.compose.project=${supabaseProjectId}" --filter "name=supabase_" --format "{{.Names}}|{{.Status}}"`
33427
+ );
33428
+ return parsePsLines(out).some((c) => c.running);
33429
+ }
33323
33430
  const isolationDrift = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
33324
33431
  __proto__: null,
33325
33432
  computeProjectDrift,
33326
33433
  detectIsolationDrift,
33327
33434
  detectUnmanagedSupabaseStacks,
33328
33435
  groupUnmanaged,
33436
+ hasIndependentHostSupabase,
33329
33437
  parsePsLines
33330
33438
  }, Symbol.toStringTag, { value: "Module" }));
33331
33439
  let dockerWatchProcess = null;
@@ -37030,7 +37138,7 @@ const projectTools = {
37030
37138
  if (a.isolation === "vm") {
37031
37139
  throw new McpError(
37032
37140
  "invalid_args",
37033
- 'register_project creates host-mode projects. To isolate, register it (host), then call switch_isolation with target_mode "vm" (or use the Supbuddy app) that provisions the VM container as an explicit step.'
37141
+ 'register_project creates host or thin projects; vm is retired. Omit `isolation` to get thin (recommended: per-project loopback IP, apps keep canonical ports like :3000), or pass "host" only when this project\'s Supabase already runs on the host outside Supbuddy.'
37034
37142
  );
37035
37143
  }
37036
37144
  const existing = useStore.getState().projects.find((p2) => p2.rootPath === a.root_path || p2.path === a.root_path);
@@ -37075,8 +37183,38 @@ const projectTools = {
37075
37183
  console.warn("[mcp] register_project scan failed:", e);
37076
37184
  }
37077
37185
  }
37186
+ let isolation_note;
37187
+ const hostRequested = a.isolation === "host" || a.isolation === void 0 && settings.defaultIsolation === "host";
37188
+ if (hostRequested) {
37189
+ isolation_note = `Registered on HOST isolation (${a.isolation === "host" ? "explicit" : "Settings → Default isolation"}). Apps share 127.0.0.1, so dev-server ports must be unique across projects. Unless this project's Supabase runs on the host outside Supbuddy, prefer switch_isolation { target_mode: "thin" } — it gives the project its own loopback IP so apps keep canonical ports (e.g. :3000).`;
37190
+ } else {
37191
+ let independent = false;
37192
+ if (a.isolation !== "thin") {
37193
+ const { hasIndependentHostSupabase: hasIndependentHostSupabase2 } = await __vitePreload(async () => {
37194
+ const { hasIndependentHostSupabase: hasIndependentHostSupabase3 } = await Promise.resolve().then(() => isolationDrift);
37195
+ return { hasIndependentHostSupabase: hasIndependentHostSupabase3 };
37196
+ }, false ? __VITE_PRELOAD__ : void 0);
37197
+ independent = await hasIndependentHostSupabase2(a.root_path, settings.supabaseSearchPaths);
37198
+ }
37199
+ if (independent) {
37200
+ isolation_note = 'Registered on HOST isolation: this project\'s Supabase stack is already running on the host outside Supbuddy — thin would rewrite its supabase/config.toml ports and orphan that stack. To opt in later: `supabase stop`, then switch_isolation { target_mode: "thin" }. On host, apps share 127.0.0.1, so dev-server ports must not collide with other projects.';
37201
+ } else {
37202
+ try {
37203
+ const { runIsolationSwitch: runIsolationSwitch2 } = await __vitePreload(async () => {
37204
+ const { runIsolationSwitch: runIsolationSwitch3 } = await Promise.resolve().then(() => isolationBridge);
37205
+ return { runIsolationSwitch: runIsolationSwitch3 };
37206
+ }, false ? __VITE_PRELOAD__ : void 0);
37207
+ await runIsolationSwitch2(project.id, "thin", { autoStart: false });
37208
+ const p2 = useStore.getState().getProject(project.id);
37209
+ isolation_note = `THIN isolation enabled${p2?.loopbackIp ? ` (loopback ${p2.loopbackIp})` : ""}: this project's apps bind their own loopback IP, so every dev server keeps its canonical port (Next.js :3000, Vite :5173) with no cross-project collisions. Start dev servers with \`supbuddy run -- <dev command>\` (it injects the bind flag); do NOT move an app to a nonstandard port to dodge a collision.`;
37210
+ } catch (e) {
37211
+ useStore.getState().updateProject(project.id, { isolation: "host", loopbackIp: void 0 });
37212
+ isolation_note = `Registered on HOST isolation: enabling thin failed (${e.message}). Retry with switch_isolation { target_mode: "thin" } — thin gives the project its own loopback IP so apps keep canonical ports (e.g. :3000). Until then apps share 127.0.0.1 and ports must be unique.`;
37213
+ }
37214
+ }
37215
+ }
37078
37216
  const fresh = useStore.getState().getProject(project.id);
37079
- return { ...fresh, __reversible_via: { tool: "delete_project", args: { id: project.id } } };
37217
+ return { ...fresh, isolation_note, __reversible_via: { tool: "delete_project", args: { id: project.id } } };
37080
37218
  },
37081
37219
  update_project: async (a) => {
37082
37220
  const p2 = useStore.getState().getProject(a.id);
@@ -37415,7 +37553,7 @@ const executors = {
37415
37553
  started: true,
37416
37554
  project_id: args.project_id,
37417
37555
  target_mode: args.target_mode,
37418
- note: "Isolation switch started in the background provisioning a VM (DinD) and starting Supabase can take several minutes. Poll get_project (migrationState / vmState / isolation) and get_supabase_status for completion."
37556
+ note: args.target_mode === "thin" ? 'Thin switch started in the background (usually seconds: loopback alias + proxy repoint; a sudo prompt may appear if the alias is new). Poll get_project until isolation is "thin" and loopbackIp is set — if it stays "host", the switch failed (e.g. dismissed sudo prompt); retry. Then start dev servers with `supbuddy run -- <dev command>` so apps bind the loopback IP and keep canonical ports like :3000.' : "Switch to host started in the background — restoring config.toml and stopping the managed Supabase stack can take a while. Poll get_project (migrationState / isolation) and get_supabase_status for completion."
37419
37557
  };
37420
37558
  },
37421
37559
  migrate_vm_to_thin: async (args, _ctx) => {
@@ -39905,7 +40043,7 @@ async function detectTargets(projectPath) {
39905
40043
  jetbrains: await isDir(path.join(projectPath, ".idea"))
39906
40044
  };
39907
40045
  }
39908
- const DOCS_MARKDOWN = '# Supbuddy docs\n\n> Run multiple Supabase projects at once on one Mac, each with its own custom local domain.\n\n## Getting started\n\n### 1. Install\n\nDownload the latest `.dmg` from the [download page](/api/download). Drag **Supbuddy.app** into `/Applications` and launch it. Supbuddy is signed and notarized; macOS will not show a Gatekeeper warning. Requires an Apple Silicon Mac (M1/M2/M3/M4, arm64). The desktop app is macOS-only in v2, but the headless CLI runs on Linux too. See [Command-line interface](#command-line-interface-cli).\n\n### 2. Trust the local Certificate Authority\n\nOn first launch, open the app and click **Install Certificate** when prompted. Supbuddy generates a local CA (Caddy\'s internal PKI) at `~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt` and installs it into your system Keychain via `sudo security add-trusted-cert`. macOS will ask for your password once. After this, every Supbuddy domain gets the green padlock automatically, with no per-domain prompts and no browser warnings.\n\nIf Supbuddy detects an AI tool that ships its own JavaScript runtime (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, etc.) it will also offer to enable **Bundled-runtime trust** in the same first-run prompt. Those tools don\'t read the system Keychain (they carry their own Mozilla CA bundle), so without this setup the first OAuth/MCP connection to a `*.test` URL fails with `unable to get local issuer certificate`. Enable it once and Supbuddy keeps it in sync (including across yearly Caddy CA rotation). See the **Bundled-runtime trust** section under Settings → General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings → Network** tab.\n\n### 3. Add your first project\n\nClick **Add project** in the Configure tab and pick a project root folder (the one with `package.json` and/or `supabase/config.toml`). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:\n\n- Supabase Kong → `api.<project>.test`\n- Supabase Studio → `studio.<project>.test`\n- Supabase Inbucket / Mailpit → `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) → `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings → General → Default TLD**.\n\n### 4. Start the proxy\n\nToggle the project on. Supbuddy starts Caddy on port 8443 (HTTPS) and starts its built-in DNS server on port 5353. If you want real ports 80/443 instead of 8080/8443, enable **port forwarding** in **Settings → Network**. Supbuddy adds a `pfctl` redirect rule (asks for sudo once).\n\n## Core concepts\n\nFour things to understand:\n\n- **Project**: a folder you registered. Holds detected *apps* (Next.js, Vite, etc.), detected *services* (Supabase stack, Docker Compose services), and a list of *mappings*.\n- **Mapping**: a domain → port pair (e.g. `api.acme.test → 54321`). Auto-generated mappings are tied to a detected service or app; you can also create manual ones.\n- **Isolation mode**: per-project. One of:\n - `host` (default): Supabase runs on your host Docker on the stock ports. Only one host-mode Supabase project can run at a time (the standard `supabase start` constraint).\n - `thin` (lightweight, recommended): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own **port block** and a unique Compose `project_id`, written into that project\'s `supabase/config.toml`. That\'s what lets several Supabase projects run **at once on the shared daemon**, each reached by name (`api.<project>.test`, `studio.<project>.test`). Apps bind a per-project loopback address so they keep their canonical ports too. Supbuddy owns those config.toml keys while the project is `thin` and restores them the moment you switch back to `host`. This is the lightest way to run many Supabase stacks together.\n- **Active vs inactive**: any project can be "active" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.\n\n## Project cards (Configure tab)\n\nEach registered project appears as a card in the Configure tab. Cards have a single-row header that\'s always visible and a tab-based body that expands on click.\n\n### Header\n\nReading left to right:\n\n- **Expand chevron** + **project name**: click to expand/collapse the card.\n- **Status indicator**: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem\'s state.\n- **Tech badges**: e.g. `TurboRepo`, `Supabase` (shown when detected).\n\n**Supabase connection warning.** When a project\'s app `.env` is missing the\nSupabase connection vars, or they\'ve gone stale relative to the live target\n(e.g. after switching isolation, which republishes ports), the card shows a\n`supabase env: not connected` / `supabase env: out of date` pill. Click it to\nopen Connect and push fresh values, or choose **Ignore for this project**.\n- **Env mode chip**: read-only `Host` or `Thin` label (matching the project\'s isolation mode). To switch modes, open the **Supabase** tab and use the **Environment** section at the top.\n- **Issues counter**: red for errors, amber for warnings. Click to open the **issues popover** (see below). Hidden when there are no issues.\n- **Warnings chip**: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.\n- **Enable toggle** (right edge): turn the project\'s proxy on/off without deleting it.\n- **⋯ actions menu** (right edge): every project-level action: **Edit project**, **Rescan**, **Re-check configs** (re-runs the connection/env drift check for this project), **Select folder**, **Export bundle**, and **Delete project**.\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings. Each issue shows a severity icon, title, optional detail, and a **→ open {tab}** link. Clicking the link jumps to the relevant tab and closes the popover.\n\n### Body tabs (when expanded)\n\nThe body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header\'s **⋯ menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain → :port` (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed **edit** / **delete** / **access** (LAN / Tailscale state) actions and the per-mapping **toggle** to the right edge. A **Map** CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.\n\n#### Supabase (shown when Supabase is detected)\n\n**Environment section (top):** host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see [Migrating a legacy Isolated (VM) project to Thin](#migrating-a-legacy-isolated-vm-project-to-thin)).\n\n**Action bar:** Start, Stop, Restart buttons; a first-class **Connect** button (cyan, opens the connection panel for `.env` generation / merge); and a **More** menu with **Config editor** and **Details**.\n\n**Config editor: secret extraction.** When you save a `supabase/config.toml` that contains a secret-bearing value inline (e.g. an SMTP password under `[auth.email.smtp]`, an OAuth `secret`, or any `*_key`/`auth_token`), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root `.env.local`). The value is written there and replaced in `config.toml` with an `env(SUPABASE_…)` reference, so secrets never land in git. Supbuddy injects those `SUPABASE_`-prefixed values back into the `supabase start` environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)\n\n**Service rows** (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.\n\n#### Compose (shown when Compose services are detected)\n\n**Action bar:** Start, Stop, Restart. **Service rows** are read-only (status dot, name, URL). Add-on services declared in `supbuddy.addons.yml` (see **Add-on Compose services**) appear here alongside the base stack and in `get_compose_status` over MCP.\n\n#### Other (shown when non-Supabase, non-Compose services are detected)\n\nRead-only service rows: status dot, name, URL.\n\n#### Scripts (shown when scripts are detected)\n\nBookmarked scripts appear in a **Quick Access** group at the top; remaining scripts appear under **Other Scripts**. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.\n\n#### AI Tools\n\nWraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target **scope** (global / local), advanced options, and recent activity. See [Per-project AI context sync](#per-project-ai-context-sync) for what global vs. local means.\n\n> Project-level actions (**Edit**, **Rescan**, **Re-check configs**, **Select folder**, **Export bundle**, **Delete**) are no longer a tab. They live in the header\'s **⋯ actions menu**.\n\n---\n\n## Multiple Supabase projects (the main use case)\n\nThe reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 Inbucket). Two projects on the same machine collide; you must `supabase stop` one before `supabase start`-ing the other.\n\nTwo ways to break that constraint, picked per project in the **Supabase** tab → **Environment** section:\n\n### Thin (lightweight, recommended)\n\nSwitch a project to **Thin**. Supbuddy assigns it a free port block (in the `55000+` range), writes those ports plus a unique Compose `project_id` into its `supabase/config.toml`, and runs `supabase start` on your **normal host Docker**, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (`api.acme.test`, `studio.acme.test`, `mail.acme.test`). Switch back to **Host** and Supbuddy restores the original `config.toml` and stops just that project\'s stack.\n\nThis is the lightest, fastest option and the right default for most setups. One caveat: if your `config.toml` omits a port key (e.g. `[inbucket] smtp_port`), Supbuddy can\'t relocate a port that isn\'t declared, so that one service falls back to its stock port. That is fine for a single project, but spell those keys out if two Thin projects need the same service.\n\n### Running them all at once\n\nRegister as many projects as you want, and all of them can be "active" (proxied) at the same time. There\'s no limit. A Thin project\'s stack restarts in seconds; a Host project needs the standard `supabase start` cycle.\n\n### Migrating a legacy Isolated (VM) project to Thin\n\nIf you created a project in an older version of Supbuddy that used the now-retired **Isolated (VM)** mode, Supbuddy detects it on launch and offers a one-way, guided migration to **Thin**. The migration wizard appears in the **Supabase** tab\'s Environment section for any project still flagged as VM.\n\nThe migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there\'s no way to switch back (but your data is intact in the Thin stack).\n\nOver MCP, three tools handle the migration bridge:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode awaiting migration.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration (dump, restore, verify).\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.\n\n## Custom domains & TLDs\n\nEvery mapping resolves through Supbuddy\'s built-in DNS server on port 5353. By default the TLD is `.test` (an IETF-reserved TLD safe for local use). You can change the default in **Settings → General → Default TLD** to `local`, `dev`, or anything else; existing mappings are migrated to the new TLD on save.\n\nFor host resolution, Supbuddy *does not* use `/etc/hosts` for wildcards; it runs a DNS resolver. macOS\'s default resolver only queries port 53; Supbuddy installs a per-project resolver file under `/etc/resolver/<project-domain>` (e.g. `/etc/resolver/myapp.local`) pointing at `127.0.0.1:5353`. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like `.local` (which Bonjour/mDNS owns). You\'ll be prompted for sudo the first time this changes.\n\n### LAN sharing\n\nWhen LAN sharing is enabled (Settings → Network), Supbuddy binds Caddy to `0.0.0.0` instead of `127.0.0.1` and runs an mDNS responder so other machines on your local network can reach your dev servers via `<hostname>.local`. Useful for testing on your phone or another laptop without setting up Tailscale.\n\n**`.local` TLD + LAN sharing:** macOS reserves the `.local` namespace for Bonjour/mDNS (RFC 6762), and macOS\'s TCP stack short-circuits self-connections to your own LAN IP via the loopback path *without consulting `pf`*, so the obvious "redirect lo0 → my LAN IP" trick can\'t fix it. Supbuddy\'s mDNS responder works around this by **ignoring queries that originate from this machine**, letting the OS resolver fall through to `/etc/resolver/<project-domain>` (which routes to `127.0.0.1` where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: `.local` works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to `.test`, you can switch back.\n\nIf `studio.<project>.local` (or similar) doesn\'t load: open the Configure tab. A red banner will tell you whether it\'s a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.\n\n### Tailscale\n\nIf you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.\n\n## Monorepo support\n\nSupbuddy auto-detects these monorepo layouts when scanning a project root:\n\n- Turborepo (presence of `turbo.json`)\n- pnpm workspaces (`pnpm-workspace.yaml`)\n- npm/yarn workspaces (`workspaces` field in root `package.json`)\n- Common folder layouts: `apps/*`, `packages/*`, `services/*`, `sites/*`\n\nEach detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: `apps/*`, `packages/*`, `services/*`, `sites/*`, `db/`, `db/*`, `database/`, `database/*`, `packages/backend`, `packages/db`, `packages/database`.\n\n### Detected app frameworks\n\nPort detection looks for the framework dependency in `package.json` and combines that with: explicit `-p`/`--port` in the dev script, `PORT=` env in the dev script, or a config file read. If none of those resolve, the framework default is used:\n\n| Framework dependency | Default port |\n| --- | --- |\n| `next` | 3000 |\n| `vite` | 5173 |\n| `@remix-run/dev`, `@remix-run/serve` | 3000 |\n| `astro` | 4321 |\n| `nuxt`, `nuxt3` | 3000 |\n| `@sveltejs/kit` | 5173 |\n| `@angular/core` | 4200 |\n| `@nestjs/core` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | none (must be explicit in dev script) |\n\n### Server Actions allowedOrigins audit\n\nFor Next.js apps, Supbuddy reads your `next.config.{ts,mts,js,mjs,cjs}` and extracts the hosts in `experimental.serverActions.allowedOrigins`. If a mapped subdomain is missing from that list, the project\'s **warnings chip** flags `next.config: N origins missing`; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the **Apps** tab (the chip\'s "open apps" jump) where the affected app shows the warning with a **Fix** button.\n\nThe Fix button opens a dialog with a paste-ready snippet and an **Apply…** button: click it to see a unified diff of the change Supbuddy will make to your `next.config`, then **Confirm & write** to apply it. Supbuddy handles the four common config shapes (existing `allowedOrigins` array, existing `serverActions` block without it, existing `experimental` block without `serverActions`, or no `experimental` at all). After write, Supbuddy rescans the project so the warning disappears immediately. Restart your dev server for the change to take effect; Next.js does not hot-reload `next.config`.\n\n### Vite allowedHosts audit\n\nFor Vite apps, Supbuddy reads your `vite.config.{ts,mts,cts,js,mjs,cjs}` and extracts `server.allowedHosts`. If a mapped host isn\'t covered, the **warnings chip** flags `vite: N hosts blocked`; Vite\'s dev server otherwise rejects proxied requests for unknown hosts with `Blocked request. This host ("…") is not allowed.` (403). A `.your-project.local` entry counts as covering every subdomain, so an existing wildcard suffix doesn\'t trigger a false warning.\n\nLike the Next.js audit, the affected app\'s **Fix** button on the **Apps** tab opens a dialog with a paste-ready snippet and an **Apply…** button that previews a unified diff and writes `server.allowedHosts` into your `vite.config` (handling an existing `allowedHosts` array, an existing `server` block without it, or no `server` block at all; `allowedHosts: true` is left untouched). After write, Supbuddy rescans so the warning clears. Restart your dev server for the change to take effect; Vite does not hot-reload `vite.config`.\n\n## MCP setup (AI agents)\n\nSupbuddy ships a built-in MCP server on `http://127.0.0.1:9877/mcp` with static Bearer-token auth. Five clients have one-click install; any other MCP-compatible tool can be configured manually with the same URL + token.\n\nOpen **Settings → MCP → Add client**, pick the client kind, and Supbuddy generates a token, edits the client\'s config file, and backs up the original (`<file>.supbuddy-backup` next to it).\n\n### Auto-install paths\n\n| Client | Config file | Transport |\n| --- | --- | --- |\n| Claude Code | `~/.claude.json` (user) or `<project>/.mcp.json` (project) | HTTP |\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | stdio shim via `npx -y @supbuddy/mcp@latest` |\n| Cursor | `~/.cursor/mcp.json` (user) or `<project>/.cursor/mcp.json` (project) | HTTP |\n| Codex CLI | `~/.codex/config.toml` (adds an `[mcp_servers.supbuddy]` block) | HTTP |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` | HTTP |\n\n### MCP tool surface\n\nThe MCP server has full read and write access:\n\n- Read tools (`list_mappings`, `list_projects`, `get_health`, `get_compose_status`, `list_pending_vm_migrations`, etc.), with env values and request bodies included.\n- `get_client_capabilities` and `request_scope_elevation` (scope discovery + user-approved grant).\n- `read_env_file`, `tail_request_logs`, `watch_audit_log`.\n- Write tools: `create_mapping`, `delete_mapping` (soft-delete), `register_project`, `update_project`, `set_supabase_config_path`, `start_proxy`, `start_supabase`, `stop_supabase`, `restart_supabase`, `switch_isolation`, `migrate_vm_to_thin`, `finish_vm_migration`, `start_compose`, `stop_compose`, `restart_compose`, `scaffold_addons`, `seed_addons`, `write_env_file`, `copy_env_var`, `write_supabase_config`.\n- Scripts tools (`list_scripts`, `start_script`, `stop_script`, `restart_script`, `bookmark_script`, `tail_script_logs`); see *Scripts MCP tools* below.\n- Extended Supabase tools: `init_supabase`, `validate_supabase_config`, `list_supabase_backups`, `restore_supabase_backup`, `cancel_supabase_start`, `force_recreate_supabase`, `restart_supabase_container`, `get_supabase_analytics`, `set_supabase_analytics`.\n- Bundle (export/import a project\'s full config): `export_bundle`, `import_bundle`, `validate_bundle`.\n- Connection / env-target workflow: `preview_connection`, `get_env_targets`, `diff_env`, `apply_env`, `write_connection`, `test_connection`, `dismiss_connection_drift`.\n- Host & network tools: bundled-runtime trust (`get_trust_status`, `install_trust`, `remove_trust`, `detect_trust_tools`, `test_trust`), Tailscale (`get_tailscale_status`, `set_tailscale_key`, `remove_tailscale_key`, `test_tailscale`), DNS (`get_dns_status`), CA (`uninstall_ca`), and port-forwarding (`get_port_forwarding_status`, `set_port_forwarding`, `reload_port_forwarding`).\n- `tail_service_logs`: streams a Compose/add-on service\'s container logs over SSE (like `tail_request_logs` but for container stdout/stderr).\n- `watch_supabase`: streams a project\'s live Supabase start/stop/restart progress over SSE: operation status, image-pull/service snapshots, and (for VM projects) raw log lines. Backs `supbuddy supabase start --follow`.\n- Multiple MCP clients can connect simultaneously. The same MCP-HTTP surface backs the headless **CLI** (see *Command-line interface* below).\n\n### Scopes: discovery & self-service elevation\n\nEach MCP client holds a set of **scopes** (`read`, `log_tail`, `mappings`, `projects`, `services`, `config`, `system`, `apply`) chosen when it\'s added. A tool call that needs a scope the client lacks fails with `scope_denied`, whose payload now carries a `user_message` and `details.remediation` pointing at the fix.\n\n- `get_client_capabilities` ( `{ tool? }` ) returns the calling client\'s `granted_scopes` and `available_scopes`. Pass a `tool` name to get `{ required_scope, required_feature, can_call, reason? }` so an agent can pre-flight a call instead of probing by hitting `scope_denied`.\n- `request_scope_elevation` ( `{ scopes: [...] }` ) asks the **user** to grant the named scopes. Supbuddy shows a blocking approval dialog; on approval the scopes are added to the client. Already-granted scopes short-circuit without a prompt.\n\nYou can also review and edit any client\'s scopes from the GUI: **Settings → MCP → Clients** lists each client\'s granted scopes inline and exposes a **Scopes** button that opens the same scope editor used when adding a client.\n\n### Registering a project via MCP\n\n`register_project` takes a `root_path` (required), an optional `label`, and `auto_scan` (default `true`). It creates a **host**-mode project the same way the GUI\'s "Add project" flow does:\n\n- Derives a base domain as `<slug>.<defaultTld>` from the label (or the folder name), e.g. `staffhub.test`.\n- Records both the project `path` and `rootPath` so the project is visible to the proxy, scans, and file tools alike.\n- Scans the folder (unless `auto_scan: false`) for apps, services, scripts, and package manager.\n- Creates per-app subdomain mappings from the discovered apps (e.g. `site.staffhub.test → :3400`), derives the host service subdomains (`api.`, `studio.`, …), and reloads Caddy.\n\n`register_project` creates a **host**-mode project. Use `switch_isolation` afterward to move it to **thin** mode.\n\n### Switching isolation over MCP\n\n`switch_isolation` ( `{ project_id, target_mode: \'host\' | \'thin\', auto_start? }` ) moves an existing project between **host** and **thin** mode. To-thin writes the per-project port block and `project_id` into `supabase/config.toml` and (unless `auto_start: false`) starts Supabase; to-host restores the original `config.toml` and stops that project\'s stack. It runs in the background and returns `{ started: true }`; poll `get_project` (`isolation`) for the current mode.\n\nA project can also be patched with `update_project`: its `patch` accepts `name`, `enabled`, `domain`, and `isolation` (it intentionally does **not** accept `path`/`rootPath`). Note that patching `isolation` only flips the flag; use `switch_isolation` to actually provision/tear down the port assignment.\n\n### Legacy VM migration over MCP\n\nFor projects still on the retired Isolated (VM) mode, three tools handle the one-way migration to Thin:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode, with their current `vmState` and migration readiness.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration. It dumps Postgres data from the VM, starts a fresh Thin stack, restores the dump, and row-count-verifies before signalling completion. Returns `{ started: true }`; poll `get_project` (`migrationState`) for progress.\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after verification passes. Errors if called before the verify step completes.\n\n### Repointing a project\'s Supabase config\n\n`set_supabase_config_path` ( `{ project_id, supabase_path }` ) switches which `supabase/config.toml` a project uses, for monorepos that carry more than one (e.g. a repo-root config and an app-level one). `supabase_path` is the project-relative directory **containing** the `supabase/` folder (`"."` for the repo root, e.g. `"apps/getnightowls"`). It persists the path, re-derives `supabaseProjectId` from the new config, and re-scans services. The previous stack\'s Docker volume is **left intact** (not deleted), so the switch is reversible; the response reports it under `orphaned_previous_stack`.\n\n### Moving a secret between env files\n\n`copy_env_var` ( `{ source_path, source_key, target_path, target_key? }` ) relocates a single variable from one env file to another (e.g. a value put in an app\'s `.env.local` that the stack actually injects from the repo-root `.env.local`). The value is read and written entirely inside the worker (it **never crosses the MCP boundary** and never appears in the audit log), so an agent can move a secret without it being printed. `target_key` defaults to `source_key`.\n\n### Plan / apply for destructive tools\n\nTools that delete or mutate state (`delete_mapping`, `delete_project`, `write_env_file`, etc.) return a *plan* with a preview. The MCP client (or you, in the Activity panel) explicitly calls `apply` with the `plan_id` to execute. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days.\n\n## Add-on Compose services\n\nA project can declare **extra** Docker Compose services that Supbuddy discovers, merges, runs, health-checks, and tails alongside the managed stack: a Redis cache, a worker queue, a search engine, etc. Add-on services run on the host\'s shared Docker daemon in both `host` and `thin` isolation, with no extra setup needed.\n\n### Declaration files & merge precedence\n\nSupbuddy looks for up to three Compose fragments in the project and merges them, later wins:\n\n1. `docker-compose.yml`: your base Compose file.\n2. `docker-compose.override.yml`: your own override, honored if present (standard Compose convention).\n3. `supbuddy.addons.yml`: Supbuddy-owned add-on fragment.\n\nAll present fragments are passed explicitly, e.g. `docker compose -f docker-compose.yml -f docker-compose.override.yml -f supbuddy.addons.yml --project-name <pinned> …`. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project\'s default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.\n\n### `supbuddy.addons.yml` format\n\nA valid Compose fragment (a standard `services:` map) plus an optional Supbuddy-only `x-supbuddy:` extension block. A plain `docker compose up` ignores `x-supbuddy:`, so the file stays usable without Supbuddy. Today `x-supbuddy` supports a one-shot **seed** step:\n\n```yaml\nservices:\n redis:\n image: redis:7-alpine\n ports: ["6379:6379"]\nx-supbuddy:\n seed:\n service: redis\n command: ["redis-cli", "ping"] # explicit argv, runs once after services are healthy\n runOnce: true\n```\n\nThe seed step runs **once** after the add-on services are up and healthy. It\'s idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the `seed_addons` MCP tool.\n\n### MCP tools\n\n- `scaffold_addons` ( `{ project_id }` ): scope `config`. Creates a starter `supbuddy.addons.yml` if the project doesn\'t have one. Never clobbers an existing file.\n- `seed_addons` ( `{ project_id, force? }` ): scope `services`. Runs the declared `x-supbuddy.seed` step. Idempotent unless `force: true`.\n- `tail_service_logs` ( `{ project_id, service }` ): scope `log_tail`. Streams a Compose/add-on service\'s container logs over SSE (like `tail_request_logs`, but for container stdout/stderr).\n- `watch_supabase` ( `{ project_id }` ): scope `log_tail`. Streams a project\'s live Supabase start/stop/restart progress over SSE: `operation` (status + message), `progress` (image-pull/service snapshots), and `log` (raw lines, VM projects). The stream ends on a terminal status. Backs `supbuddy supabase start --follow`.\n\n### Scripts MCP tools\n\nScripts detected in a project (e.g. `dev`, `build`, `test`) are controllable over MCP:\n\n- `list_scripts` ( `{ project_id }` ): scope `read`. Returns all detected scripts with their current status and bookmark state.\n- `start_script` ( `{ project_id, script }` ): scope `services`. Starts the named script process.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process.\n- `bookmark_script` ( `{ project_id, script, bookmarked }` ): scope `services`. Pins (`bookmarked: true`) or unpins a script in the Quick Access group.\n- `tail_script_logs` ( `{ project_id, script }` ): scope `log_tail`. Streams the named script\'s stdout/stderr over SSE.\n\n### `get_compose_status` shape\n\n`get_compose_status` ( `{ project_id }` ) returns live per-service status, not just whether Compose is installed:\n\n```json\n{\n "project_id": "…",\n "compose_installed": true,\n "running": true,\n "services": [\n { "name": "redis", "status": "running", "health": "healthy", "ports": ["6379:6379"], "image": "redis:7-alpine", "container_id": "…", "source": "addons" }\n ]\n}\n```\n\nEach service\'s `source` is one of `base` | `override` | `addons`, telling you which fragment declared it.\n\n## Per-project AI context sync\n\nEach project has a **Context sync: AI tools** panel, accessible via the **AI Tools** tab in the project card, that writes a project-scoped briefing to disk so AI agents working in that repo see your live mappings, services, and isolation state without having to ask. Files written:\n\n- `.supbuddy/`: `README.md`, `mappings.md`, `services.md`, `project.md`, `mcp.md`, `do-not.md`, `docs.md`. The full live snapshot, regenerated on each sync.\n- `AGENTS.md` and `CLAUDE.md`: a small managed block prepended (or updated in place) telling the agent which project this is and pointing it at `.supbuddy/`.\n- Editor skill files when detected: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.github/copilot-instructions.md`, `.idea/supbuddy.md`.\n- `.gitignore` managed block, ignoring: `.supbuddy/meta.json` (volatile sync state), `*.supbuddy-backup-*` (rollback snapshots), and the per-editor skill files that are written **locally** (see scope below). The rest of `.supbuddy/` is intended to be committed; `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are also kept committable since you may have hand-written content there alongside Supbuddy\'s managed block.\n\n### Global vs. local scope\n\nThe per-editor skill files are generic Supbuddy-owned pointers ("this is a Supbuddy project: read `.supbuddy/`, prefer the MCP tools"). For editors that expose a **Supbuddy-owned global location**, Supbuddy writes that pointer **once, machine-wide** instead of copying it into every project, so it isn\'t duplicated across all your repos. Project-specific data always stays local in `.supbuddy/`.\n\n- **Claude Code** → one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** → `~/.cursor/skills/supbuddy/SKILL.md`. The global skill self-scopes: it only acts when the working directory has a `.supbuddy/` folder, and resolves the active project from that folder\'s `meta.json`.\n- All other targets (`windsurf`, `continue`, the `AGENTS.md`/`CLAUDE.md`/Copilot managed blocks, JetBrains) stay **local**: their "global" files are shared user files, so Supbuddy won\'t overwrite them.\n- Each target has a **scope** setting: `auto` (default: global for the Claude/Cursor skills, local for everything else), `global`, `local` (force per-project, useful if you commit the file for teammates), or `off`. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does **not** auto-remove these global files; delete them manually from `~/.claude/skills/supbuddy/` and `~/.cursor/skills/supbuddy/` if needed.\n- The always-loaded `CLAUDE.md`/`AGENTS.md` managed block stays local as a safety net so agents stay aware even if the on-demand global skill doesn\'t auto-activate.\n\nSync modes per project:\n\n- **Auto**: Supbuddy regenerates the files whenever mappings, services, or project state change.\n- **Manual only**: files are only written when you click **Sync now** (or use the tray\'s *Sync AI context for all projects*).\n- **Off**: nothing is written.\n\nThe collapsed header shows an at-a-glance status pill: mode (`auto` / `manual` / `off`), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn\'t present) appear greyed out in the **Detected targets** list inside the panel.\n\n## Command-line interface (CLI)\n\nEverything the desktop app can do is also driveable headlessly from a terminal, with no Electron and no window. The CLI runs a **daemon** (the same worker process the GUI uses: Caddy proxy, DNS, Supabase/Compose lifecycle, MCP-HTTP) and a set of commands that attach to it over the local MCP-HTTP port. This is for SSH sessions, CI, `tmux`/server boxes, and scripting.\n\nThe binary is `supbuddy`, with a short alias `sup`. Run `supbuddy help` for the full usage list.\n\nYou can install the CLI on its own, without the desktop app:\n\n```bash\nnpx supbuddy@latest # asks to install the CLI globally (supbuddy + sup)\n```\n\nThat command does nothing on its own except offer to put `supbuddy` and `sup` on your PATH. The CLI runs independently of the desktop app, so you can add the app later (or never). On a Mac the app installs the same two commands for you.\n\n### The daemon\n\n```bash\nsupbuddy daemon --detach # start the worker in the background\nsupbuddy status # daemon + proxy health\nsupbuddy stop # graceful shutdown\n```\n\n`--detach` backgrounds the daemon and prints its pid + ports. Foreground `supbuddy daemon` runs it attached (Ctrl-C shuts it down cleanly). On start the daemon writes a discovery file, `daemon.json` (mode `0600`), into the shared state dir holding its pid, the Socket.IO port, the MCP-HTTP port, and a control token; every other command reads it to find and authenticate to the daemon, so you never pass ports or tokens by hand. Only one daemon may run per state dir; a second `daemon` start is refused.\n\nThe CLI and the desktop app **share one state dir** (`~/Library/Application Support/Supbuddy/`), so they manage the same projects, mappings, and settings. They must not run two workers against it at once: if you launch the desktop app while a CLI daemon is running, the app detects it and offers to **stop the daemon and continue** or **quit**. It never forks a competing worker (which would corrupt `state.json`).\n\n### Run on login (service)\n\n```bash\nsupbuddy service install # start-on-login (launchd on macOS, systemd-user on Linux)\nsupbuddy service status\nsupbuddy service uninstall\n```\n\n### Commands\n\nAll app surfaces have a command. Names follow `supbuddy <module> <action> [args] [--flags]`. The main groups:\n\n| Group | Examples |\n| --- | --- |\n| Health / proxy | `status`, `proxy status\\|start\\|stop\\|restart` |\n| Mappings | `map ls\\|add\\|get\\|set\\|enable\\|disable\\|rm\\|restore` |\n| Projects | `project ls\\|add\\|get\\|scan\\|set\\|enable\\|disable\\|rm\\|restore\\|env\\|refresh-context` |\n| Supabase | `supabase start\\|stop\\|restart\\|status <proj>` (add `--follow` to stream live progress), `supabase config apply <proj> <file>` |\n| Compose | `compose up\\|down\\|restart\\|status\\|logs <proj> [svcs]` |\n| Scripts | `scripts ls\\|start\\|stop\\|restart\\|logs\\|bookmark <proj> [script]` |\n| Isolation | `isolation switch <proj> <host\\|thin>`, `isolation pending-migrations`, `migrate start\\|finish <uuid>` |\n| Certificates | `ca status\\|install\\|uninstall` |\n| Env files | `env copy <src> <key> <target>`, `env write <path> <K=V>…` |\n| Settings | `settings get`, `settings set --json <patch>` |\n| MCP | `mcp add [<agent>]` (register Supbuddy into a coding agent: interactive, or `--write`/`--print`/`--prompt`), `mcp ls`, `mcp revoke <id>`, `mcp approvals apply\\|cancel <id>` |\n| Host / network | `connect`, `trust`, `tailscale`, `dns`, `pf` (port-forwarding) |\n| Logs | `logs requests [-f]`, `logs audit [-f]`, `logs get <id>` |\n| Account | `account`, `caps`, `addons scaffold\\|seed <proj>` |\n| Dashboard | `tui` (alias `dash`) |\n\nGlobal flags: `--json` (machine-readable output), `--yes` (skip confirmations), `--quiet`, `--url`/`--token` (attach to a specific/remote daemon instead of auto-discovery), `--state-dir` (override the shared dir), `--timeout`, and `-f`/`--follow` for streaming log commands and live `supabase start|stop|restart` progress.\n\nDestructive operations go through the same **plan → apply** gate as MCP (see *Plan / apply for destructive tools*); the CLI\'s control token is granted auto-apply, so they execute directly.\n\n### Live dashboard (TUI)\n\n```bash\nsupbuddy tui # or: sup dash\n```\n\n`supbuddy tui` opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project\'s isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press `r` to refresh, `q` to quit. It needs a running daemon (`supbuddy daemon --detach`); if none is found it tells you so.\n\n## Settings reference\n\nOpen Settings via the gear icon top-right or by clicking the tray icon → Open Dashboard → gear. Five tabs.\n\n### General\n\n- **Theme**: dark or light.\n- **Auto-start at login**: registers Supbuddy as a macOS login item. Default: on.\n- **Default TLD**: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default: `test`.\n- **Default isolation**: `host` or `thin` for newly added projects. Default: `host`.\n- **Auto-subdomain mapping**: when on, services and apps detected during a project scan get mappings created automatically. Default: on.\n- **Bundled-runtime trust**: installs Supbuddy\'s local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, …) actually read. These apps don\'t consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to `*.test` with `unable to get local issuer certificate`. Default: prompted on first launch when one of those tools is detected.\n - **macOS**: writes `~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plist` and calls `launchctl setenv NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE` so GUI-launched apps inherit the right vars at process-start time.\n - **Linux**: writes `~/.config/environment.d/supbuddy-ca.conf` (read by systemd-aware user sessions on GNOME/KDE/Sway/etc.).\n - **Windows**: per-user `setx NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE` to `HKCU\\Environment`.\n - All three env vars point at `~/Library/Application Support/Supbuddy/ca-bundle/current.crt` (or the platform-equivalent), a *cumulative* concatenated PEM Supbuddy maintains. When Caddy rotates its root (yearly today, sometimes more), Supbuddy appends the new root automatically; long-running TLS contexts holding the old root keep working until the process restarts.\n - **Test trust**: runs an in-process HTTPS request against the first available `*.test` mapping with the same env vars set, to verify end-to-end without relaunching anything.\n - **Conflict refusal**: if `NODE_EXTRA_CA_CERTS` is already set to something else (corporate proxy, Zscaler), Supbuddy refuses to overwrite and surfaces the conflicting path. You can override with the explicit prompt that pops up on Install.\n - **Quit and relaunch your AI tools** after install: the env var only takes effect for *newly-launched* processes.\n\n### Network\n\n- **HTTP port**: default 8080.\n- **HTTPS port**: default 8443.\n- **DNS port**: default 5353.\n- **Port forwarding**: when on, adds a `pfctl` rule mapping 80→HTTP port and 443→HTTPS port. Asks for sudo once.\n- **LAN sharing**: binds Caddy to `0.0.0.0` + starts mDNS responder.\n- **Tailscale**: paste a tailnet API key to enable split-DNS push.\n- **Install / Uninstall CA**: installs Caddy\'s root cert into your Keychain. Uninstall is not yet implemented (manual remove via Keychain Access).\n\n### Storage\n\nTrash retention (per-kind), volume sizes, image-cache controls.\n\n### MCP\n\n- **Clients**: list of connected clients. Each row has a **⋯** actions menu: install, edit scopes, set-primary, rotate token, revoke.\n- **Activity**: audit log with Apply/Cancel/Undo on plan rows.\n- **Trash**: soft-deleted mappings and projects, restorable for 7 days.\n- Settings: server `enabled`, `port` (default 9877), `audit_cap` (default 5000), `trash_ttl_days` (default 7).\n\n### AI Skills\n\nInstall Supbuddy\'s agent **skill at the user level** (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a **master on/off** plus an **autosync** toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.\n\n- **Who can install at user level**: only agents whose global file Supbuddy fully **owns** and that **self-scope** (act only when the working directory has a `.supbuddy/`): **Claude Code** (`~/.claude/skills/supbuddy/SKILL.md`) and **Cursor** (`~/.cursor/skills/supbuddy/SKILL.md`). The install is reference-counted under a synthetic `__user__` ref so it persists independent of any project and is never pruned by the boot reconcile.\n- **Master ↔ project**: the AI Skills tab is the **master** (user-level). To commit a skill into a specific repo, use that project\'s **AI Tools** tab and set the target to **Project** (the old `local` scope, which writes into the repo for teammates); **User** there means the master install covers it.\n- Agents whose global file holds *your own* content (Claude `CLAUDE.md`, Codex `AGENTS.md`, Copilot, Windsurf, Continue, JetBrains) are **project-level only**: a machine-wide write there could clobber your config, so they\'re injected per-project instead.\n\n## Tray menu\n\nThe macOS menu bar tray icon opens a menu with:\n\n- **Status: …**: current proxy state (running / idle).\n- **DNS Active (:5353)**: shown when proxy is running.\n- **LAN Sharing (\\<ip\\>)**: shown when LAN sharing is on.\n- **Tailscale (\\<ip\\>)**: shown when Tailscale is connected.\n- **Start Proxy / Stop Proxy**: opens the dashboard.\n- **Projects**: each project opens a submenu with **Apps** (click to open the mapped URL), **Supabase** services (status dot + open), and **Scripts** (your bookmarked scripts as a one-click **Start <name>** / **Stop <name>** toggle), plus **Restart Supabase**/**Restart services** and **Show in Supbuddy**.\n- **Open Dashboard**.\n- **Sync AI context for all projects**: runs the project-context sync engine for every registered project (writes `.supbuddy/`, `CLAUDE.md`, `AGENTS.md`, etc.).\n- **Show Logs**: reveals `main.log` in Finder.\n- **Check for Updates...**: manual update check (only enabled in packaged builds).\n- **Quit**.\n\n## File locations\n\nAll under `~/Library/Application Support/Supbuddy/` on macOS:\n\n- `main.log` + `main.log.1`: app logs (rotates at 2 MB).\n- `state.json`: persistent state (projects, mappings, settings, MCP clients, license).\n- `caddy-data/`: Caddy\'s data dir (PKI, autosaves, certs).\n- `caddy-data/caddy/pki/authorities/local/root.crt`: the local CA cert installed in your Keychain.\n- `ca-bundle/current.crt`: cumulative PEM containing every Caddy root that has ever been emitted. Used by **Bundled-runtime trust** as the target for `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`. Real file (not a symlink) so Bun-bundled CLIs read it correctly.\n- `ca-bundle/versioned/<sha>.crt`: per-root snapshots for forensics.\n- `Caddyfile`: generated reverse-proxy config.\n- `daemon.json`: written while a headless CLI daemon is running (pid, Socket.IO + MCP-HTTP ports, control token); `0600`, removed on shutdown. Used by `supbuddy` CLI commands to discover and authenticate to the daemon, and by the desktop app to detect a running CLI daemon at launch.\n- `certs/`: legacy CA from the pre-Caddy era (unused in current builds).\n\nMCP-specific:\n\n- MCP client tokens (encrypted via Electron\'s `safeStorage`): `~/.config/Supbuddy/mcp/<client-id>.bin`\n- MCP audit log: under `~/Library/Application Support/Supbuddy/`, capped at `audit_cap` entries (default 5000).\n\n## Troubleshooting\n\n### Browser shows "Not secure" or certificate warning\n\nThe Caddy CA is not trusted. Open **Settings → Network → Install Certificate**. macOS will prompt for your password. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* → System keychain → search for "Caddy Local Authority".\n\n### "unable to get local issuer certificate" / "self signed certificate in certificate chain" from Claude Code, Cursor, MCP servers, or other AI tools\n\nThese tools ship their own bundled JavaScript runtime (Bun, Electron, pkg-bundled Node) and ignore the system Keychain. Open **Settings → General → Bundled-runtime trust** and click **Install**. Then *fully quit and relaunch* the AI tool; the env vars only take effect for newly-launched processes. Verify with `launchctl getenv NODE_EXTRA_CA_CERTS` (macOS); it should print `~/Library/Application Support/Supbuddy/ca-bundle/current.crt`. If install is refused with a conflict warning, you already have `NODE_EXTRA_CA_CERTS` set (often a corporate proxy / Zscaler), so Supbuddy won\'t silently overwrite; use the override prompt or manually concatenate the two PEMs.\n\n### "Docker is not running. Please start Docker Desktop."\n\nCompose and Supabase features need Docker. Open Docker Desktop and wait until the whale icon stops animating.\n\n### "Docker Compose is not installed"\n\nCompose v2 ships inside Docker Desktop. If you removed Docker Desktop and are using a standalone Docker daemon (e.g. Colima, Rancher), install compose: `brew install docker-compose`.\n\n### "Leftover host containers" / "isolation drift" warning on a project\n\nSupbuddy flags **isolation drift** when a project\'s running containers don\'t match its configured isolation mode, for example a **Host** project with a stale `thin`-mode stack still running, or a **Thin** project with leftover host-mode containers. Switching isolation modes doesn\'t tear down the old layer, so those containers linger, waste resources, and can shadow the project\'s real stack. The warning appears in the **warnings chip** next to the enable toggle (click it to see each item; it shows a spinner while Supbuddy re-checks), as an entry in the issues counter, and as a notice on the **Supabase** tab listing the exact containers and any data volumes.\n\n**Guided cleanup.** Open the Supabase tab → **Clean up leftovers…** to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to `…/Supbuddy/backups/<project>-<timestamp>/`). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.\n\nIf the leftover copy\'s data looks **newer** than the active one, the warning turns red; don\'t delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn\'t manage at all (e.g. a plain `supabase start`).\n\n### MCP client says "Invalid OAuth error" or "JSON Parse error: Unexpected EOF"\n\nThe MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in **Settings → MCP → the client\'s ⋯ menu → Rotate token**) or you\'re on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include `WWW-Authenticate: Bearer` so the client doesn\'t fall back to OAuth.\n\n### MCP token disappeared after app restart\n\nFixed in recent builds. If you\'re on an older version, regenerate the token. Root cause was that `addMcpClient` didn\'t trigger state persistence; the client was held in memory only.\n\n### Server Actions return 403 in a Next.js app behind Supbuddy\n\nNext.js\'s CSRF guard rejects POSTs whose Origin isn\'t in `experimental.serverActions.allowedOrigins`. Supbuddy detects this and flags it in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply…** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\n### Vite dev server returns "Blocked request. This host is not allowed." (403)\n\nVite (v5+) rejects requests whose `Host` header isn\'t in `server.allowedHosts`, so a Vite app reached through a Supbuddy domain 403s until the host is allowed. Supbuddy detects this and flags `vite: N hosts blocked` in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply…** to preview a diff and write `server.allowedHosts` into your `vite.config` directly. **Restart the Vite dev server afterward**; Vite does not hot-reload its config. A single `.your-project.local` entry covers every subdomain.\n\n### Project shows a red "PROXY ERROR" banner: domain resolves but won\'t load\n\nAfter the proxy starts, Supbuddy runs an end-to-end reachability check: it resolves a project domain through the OS resolver and tries to connect to Caddy on the HTTPS port. If the name resolves but the connection fails, the project shows a red **PROXY ERROR** banner naming the likely cause (DNS, port-forwarding, or mDNS race) plus a recovery action.\n\nThe most common case: the domain resolves to `127.0.0.1` but port 443 won\'t connect because the elevated `pfctl` 443→8443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click **Retry**; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off→on instead. If LAN sharing is **off**, disregard any "LAN sharing / Bonjour" wording in the banner; the cause is the missing forward, not mDNS.\n\n### Port already in use (8080, 8443, 5353, 9877)\n\nDefault ports: HTTP 8080, HTTPS 8443, DNS 5353, MCP 9877. Change them in **Settings → Network** / **Settings → MCP**. Find what\'s holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nQuit Supbuddy, then:\n\n```bash\n# Wipe app data (state, certs, Caddyfile, logs)\nrm -rf ~/Library/Application\\ Support/Supbuddy\n\n# Wipe MCP tokens\nrm -rf ~/.config/Supbuddy/mcp\n\n# Optional: remove the trusted CA\nsudo security delete-certificate -c "Caddy Local Authority" /Library/Keychains/System.keychain\n```\n\n## FAQ\n\n### Is Supbuddy free?\n\nYes. Supbuddy is free. Register as many projects and mappings as you want, with full HTTPS, full DNS, full Supabase isolation, and full read and write MCP access. There are no caps and no tiers.\n\n### Does Supbuddy send my data anywhere?\n\nNo. Caddy, the DNS server, and the MCP server all run locally on your Mac. The only outbound traffic is: Tailscale split-DNS push (only if you enabled it), auto-update checks (GitHub Releases), and Google Analytics on the marketing site (not the desktop app). The desktop app does not send telemetry.\n\n### Can I work offline?\n\nYes. The app works fully offline once the CA is trusted and projects are registered.\n\n### Linux / Windows support?\n\nThe desktop app is macOS-only in v2. The headless CLI and daemon also run on Linux, where `supbuddy service install` registers a `systemd-user` start-on-login unit (macOS uses `launchd`). Windows is not supported. A few desktop code paths (certutil, update-ca-certificates) anticipate other platforms but are not tested there.\n\n### Can I use my own TLD?\n\nYes. Set any TLD in **Settings → General → Default TLD**. Supbuddy installs `/etc/resolver/<project-domain>` files that tell macOS to query our DNS server for that project\'s domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.\n\n### What happens if I delete a project?\n\nThe project moves to the Trash (visible in **Settings → MCP → Trash**) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.\n\n### How do I uninstall Supbuddy?\n\n1. Quit the app.\n2. Drag **Supbuddy.app** from `/Applications` to the Trash.\n3. Optional cleanup: see "Wipe everything and start over" above.\n4. Optional: `sudo security delete-certificate -c "Caddy Local Authority" /Library/Keychains/System.keychain` to remove the trusted CA.\n\n### Where do I report a bug?\n\nEmail support with your version (visible at the bottom of the Settings popover) and the relevant lines from `~/Library/Application Support/Supbuddy/main.log`.\n';
40046
+ const DOCS_MARKDOWN = "# Supbuddy docs\n\n> Run multiple Supabase projects at once on one Mac, each with its own custom local domain.\n\n## Getting started\n\nThere are two ways to run Supbuddy. Use the **macOS desktop app** (steps below), or the **command-line interface**, which runs on macOS and Linux. For the CLI, install it with `npx supbuddy@latest` and jump to [Command-line interface](#command-line-interface-cli). The app and the CLI share the same state, so you can use either or both.\n\n### 1. Install\n\nDownload the latest `.dmg` from the [download page](/api/download). Drag **Supbuddy.app** into `/Applications` and launch it. Supbuddy is signed and notarized; macOS will not show a Gatekeeper warning. Requires an Apple Silicon Mac (M1/M2/M3/M4, arm64). The desktop app is macOS-only in v2, but the headless CLI runs on Linux too. See [Command-line interface](#command-line-interface-cli).\n\n### 2. Trust the local Certificate Authority\n\nOn first launch, open the app and click **Install Certificate** when prompted. Supbuddy generates a local CA (Caddy's internal PKI) at `~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt` and installs it into your system Keychain via `sudo security add-trusted-cert`. macOS will ask for your password once. After this, every Supbuddy domain gets the green padlock automatically, with no per-domain prompts and no browser warnings.\n\nIf Supbuddy detects an AI tool that ships its own JavaScript runtime (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, etc.) it will also offer to enable **Bundled-runtime trust** in the same first-run prompt. Those tools don't read the system Keychain (they carry their own Mozilla CA bundle), so without this setup the first OAuth/MCP connection to a `*.test` URL fails with `unable to get local issuer certificate`. Enable it once and Supbuddy keeps it in sync (including across yearly Caddy CA rotation). See the **Bundled-runtime trust** section under Settings → General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings → Network** tab.\n\n### 3. Add your first project\n\nClick **Add project** in the Configure tab and pick a project root folder (the one with `package.json` and/or `supabase/config.toml`). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:\n\n- Supabase Kong → `api.<project>.test`\n- Supabase Studio → `studio.<project>.test`\n- Supabase Inbucket / Mailpit → `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) → `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings → General → Default TLD**.\n\n### 4. Start the proxy\n\nToggle the project on. Supbuddy starts Caddy on port 8443 (HTTPS) and starts its built-in DNS server on port 5353. If you want real ports 80/443 instead of 8080/8443, enable **port forwarding** in **Settings → Network**. Supbuddy adds a `pfctl` redirect rule (asks for sudo once).\n\n## Core concepts\n\nFour things to understand:\n\n- **Project**: a folder you registered. Holds detected *apps* (Next.js, Vite, etc.), detected *services* (Supabase stack, Docker Compose services), and a list of *mappings*.\n- **Mapping**: a domain → port pair (e.g. `api.acme.test → 54321`). Auto-generated mappings are tied to a detected service or app; you can also create manual ones.\n- **Isolation mode**: per-project. One of:\n - `thin` (lightweight, **the default for newly registered projects**): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own **port block** and a unique Compose `project_id`, written into that project's `supabase/config.toml`. That's what lets several Supabase projects run **at once on the shared daemon**, each reached by name (`api.<project>.test`, `studio.<project>.test`). Apps bind a **per-project loopback IP** (127.0.0.2, 127.0.0.3, …) so every project's dev servers keep their canonical ports — each project gets its *own* `:3000`. Start dev servers with `supbuddy run -- <dev command>` so they bind that IP. Supbuddy owns those config.toml keys while the project is `thin` and restores them the moment you switch back to `host`.\n - `host`: everything shares `127.0.0.1` and the stock ports. Dev-server ports collide across projects, and only one host-mode Supabase project can run at a time (the standard `supabase start` constraint). Use `host` **only when the project's Supabase stack is already running on the host independently of Supbuddy** (you run `supabase start` yourself and don't want Supbuddy re-porting `config.toml`). MCP registration (`register_project`) detects that case and keeps such projects on `host` automatically; in the app's Add-project dialog, pick **Host** in the Environment section yourself.\n- **Active vs inactive**: any project can be \"active\" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.\n\n## Project cards (Configure tab)\n\nEach registered project appears as a card in the Configure tab. Cards have a single-row header that's always visible and a tab-based body that expands on click.\n\n### Header\n\nReading left to right:\n\n- **Expand chevron** + **project name**: click to expand/collapse the card.\n- **Status indicator**: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem's state.\n- **Tech badges**: e.g. `TurboRepo`, `Supabase` (shown when detected).\n\n**Supabase connection warning.** When a project's app `.env` is missing the\nSupabase connection vars, or they've gone stale relative to the live target\n(e.g. after switching isolation, which republishes ports), the card shows a\n`supabase env: not connected` / `supabase env: out of date` pill. Click it to\nopen Connect and push fresh values, or choose **Ignore for this project**.\n- **Env mode chip**: read-only `Host` or `Thin` label (matching the project's isolation mode). To switch modes, open the **Supabase** tab and use the **Environment** section at the top.\n- **Issues counter**: red for errors, amber for warnings. Click to open the **issues popover** (see below). Hidden when there are no issues.\n- **Warnings chip**: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.\n- **Enable toggle** (right edge): turn the project's proxy on/off without deleting it.\n- **⋯ actions menu** (right edge): every project-level action: **Edit project**, **Rescan**, **Re-check configs** (re-runs the connection/env drift check for this project), **Select folder**, **Export bundle**, and **Delete project**.\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings. Each issue shows a severity icon, title, optional detail, and a **→ open {tab}** link. Clicking the link jumps to the relevant tab and closes the popover.\n\n### Body tabs (when expanded)\n\nThe body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header's **⋯ menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain → :port` (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed **edit** / **delete** / **access** (LAN / Tailscale state) actions and the per-mapping **toggle** to the right edge. A **Map** CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.\n\n#### Supabase (shown when Supabase is detected)\n\n**Environment section (top):** host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see [Migrating a legacy Isolated (VM) project to Thin](#migrating-a-legacy-isolated-vm-project-to-thin)).\n\n**Action bar:** Start, Stop, Restart buttons; a first-class **Connect** button (cyan, opens the connection panel for `.env` generation / merge); and a **More** menu with **Config editor** and **Details**.\n\n**Config editor: secret extraction.** When you save a `supabase/config.toml` that contains a secret-bearing value inline (e.g. an SMTP password under `[auth.email.smtp]`, an OAuth `secret`, or any `*_key`/`auth_token`), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root `.env.local`). The value is written there and replaced in `config.toml` with an `env(SUPABASE_…)` reference, so secrets never land in git. Supbuddy injects those `SUPABASE_`-prefixed values back into the `supabase start` environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)\n\n**Service rows** (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.\n\n#### Compose (shown when Compose services are detected)\n\n**Action bar:** Start, Stop, Restart. **Service rows** are read-only (status dot, name, URL). Add-on services declared in `supbuddy.addons.yml` (see **Add-on Compose services**) appear here alongside the base stack and in `get_compose_status` over MCP.\n\n#### Other (shown when non-Supabase, non-Compose services are detected)\n\nRead-only service rows: status dot, name, URL.\n\n#### Scripts (shown when scripts are detected)\n\nBookmarked scripts appear in a **Quick Access** group at the top; remaining scripts appear under **Other Scripts**. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.\n\n#### AI Tools\n\nWraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target **scope** (global / local), advanced options, and recent activity. See [Per-project AI context sync](#per-project-ai-context-sync) for what global vs. local means.\n\n> Project-level actions (**Edit**, **Rescan**, **Re-check configs**, **Select folder**, **Export bundle**, **Delete**) are no longer a tab. They live in the header's **⋯ actions menu**.\n\n---\n\n## Multiple Supabase projects (the main use case)\n\nThe reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 Inbucket). Two projects on the same machine collide; you must `supabase stop` one before `supabase start`-ing the other.\n\nTwo ways to break that constraint, picked per project in the **Supabase** tab → **Environment** section:\n\n### Thin (lightweight, recommended)\n\nSwitch a project to **Thin**. Supbuddy assigns it a free port block (in the `55000+` range), writes those ports plus a unique Compose `project_id` into its `supabase/config.toml`, and runs `supabase start` on your **normal host Docker**, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (`api.acme.test`, `studio.acme.test`, `mail.acme.test`). Switch back to **Host** and Supbuddy restores the original `config.toml` and stops just that project's stack.\n\nThis is the lightest, fastest option and the right default for most setups — which is why **newly registered projects default to Thin**. One caveat: if your `config.toml` omits a port key (e.g. `[inbucket] smtp_port`), Supbuddy can't relocate a port that isn't declared, so that one service falls back to its stock port. That is fine for a single project, but spell those keys out if two Thin projects need the same service.\n\n### Dev servers on Thin: every project keeps its own `:3000`\n\nA Thin project also gets its own **loopback IP** (127.0.0.2, 127.0.0.3, …, persisted per project). Its app dev servers bind that IP instead of `127.0.0.1`, so canonical ports never collide across projects — five Next.js apps in five projects can all run on `:3000` at once, and Supbuddy's proxy routes each `web.<project>.test` to its project's IP.\n\nStart dev servers through the launcher:\n\n```bash\nsupbuddy run -- next dev # binds -H <project loopback IP>, stays on :3000\nsupbuddy run -- vite # injects --host <ip> --strictPort\nsupbuddy run --print -- next dev # show what would run, without running it\n```\n\n`supbuddy run` reads the project's IP from the nearest `.supbuddy/meta.json` (`loopbackIp`, written when Thin is enabled), ensures the loopback alias exists, injects the right bind flag for the detected framework, and execs your command. It prints one concise line with the project's Caddy-proxied URL (e.g. `[supbuddy] → https://web.<project>.test`) — the address you should actually open. For **Next and Vite** it also hides the dev server's own `- Local:/- Network:` banner (which only echoes the raw loopback IP `127.0.0.N:<port>`, bypassing Supbuddy's HTTPS proxy): those two lines are filtered out of the piped output, every other line passes through untouched, and colours are preserved via `FORCE_COLOR` (stdin stays interactive). Other frameworks pass through with no filtering. When a project has several app mappings, it matches the one whose port equals the dev server's port (from `--port`/`-p` or the framework default), else lists them all. Make it the project's `dev` script (`\"dev\": \"supbuddy run -- next dev\"`) so nobody — humans or agents — has to remember it. **Never move an app to a nonstandard port because `127.0.0.1:3000` is busy**; that port belongs to another project's IP space.\n\n### When to stay on Host\n\nKeep a project on **Host** only when its Supabase stack runs on the host *independently of Supbuddy* — you run `supabase start` yourself on the stock ports and don't want Supbuddy rewriting `config.toml`. MCP registration (`register_project`) detects a stack like that (running containers for the project's `config.toml` `project_id`) and keeps the project on Host automatically; in the app's Add-project dialog, pick **Host** in the Environment section for such projects. Stop the stack (`supabase stop`) and switch to Thin whenever you're ready.\n\n### Running them all at once\n\nRegister as many projects as you want, and all of them can be \"active\" (proxied) at the same time. There's no limit. A Thin project's stack restarts in seconds; a Host project needs the standard `supabase start` cycle.\n\n### Migrating a legacy Isolated (VM) project to Thin\n\nIf you created a project in an older version of Supbuddy that used the now-retired **Isolated (VM)** mode, Supbuddy detects it on launch and offers a one-way, guided migration to **Thin**. The migration wizard appears in the **Supabase** tab's Environment section for any project still flagged as VM.\n\nThe migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there's no way to switch back (but your data is intact in the Thin stack).\n\nOver MCP, three tools handle the migration bridge:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode awaiting migration.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration (dump, restore, verify).\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.\n\n## Custom domains & TLDs\n\nEvery mapping resolves through Supbuddy's built-in DNS server on port 5353. By default the TLD is `.test` (an IETF-reserved TLD safe for local use). You can change the default in **Settings → General → Default TLD** to `local`, `dev`, or anything else; existing mappings are migrated to the new TLD on save.\n\nFor host resolution, Supbuddy *does not* use `/etc/hosts` for wildcards; it runs a DNS resolver. macOS's default resolver only queries port 53; Supbuddy installs a per-project resolver file under `/etc/resolver/<project-domain>` (e.g. `/etc/resolver/myapp.local`) pointing at `127.0.0.1:5353`. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like `.local` (which Bonjour/mDNS owns). You'll be prompted for sudo the first time this changes.\n\n### LAN sharing\n\nWhen LAN sharing is enabled (Settings → Network), Supbuddy binds Caddy to `0.0.0.0` instead of `127.0.0.1` and runs an mDNS responder so other machines on your local network can reach your dev servers via `<hostname>.local`. Useful for testing on your phone or another laptop without setting up Tailscale.\n\n**`.local` TLD + LAN sharing:** macOS reserves the `.local` namespace for Bonjour/mDNS (RFC 6762), and macOS's TCP stack short-circuits self-connections to your own LAN IP via the loopback path *without consulting `pf`*, so the obvious \"redirect lo0 → my LAN IP\" trick can't fix it. Supbuddy's mDNS responder works around this by **ignoring queries that originate from this machine**, letting the OS resolver fall through to `/etc/resolver/<project-domain>` (which routes to `127.0.0.1` where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: `.local` works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to `.test`, you can switch back.\n\nIf `studio.<project>.local` (or similar) doesn't load: open the Configure tab. A red banner will tell you whether it's a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.\n\n### Tailscale\n\nIf you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.\n\n## Monorepo support\n\nSupbuddy auto-detects these monorepo layouts when scanning a project root:\n\n- Turborepo (presence of `turbo.json`)\n- pnpm workspaces (`pnpm-workspace.yaml`)\n- npm/yarn workspaces (`workspaces` field in root `package.json`)\n- Common folder layouts: `apps/*`, `packages/*`, `services/*`, `sites/*`\n\nEach detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: `apps/*`, `packages/*`, `services/*`, `sites/*`, `db/`, `db/*`, `database/`, `database/*`, `packages/backend`, `packages/db`, `packages/database`.\n\n### Detected app frameworks\n\nPort detection looks for the framework dependency in `package.json` and combines that with: explicit `-p`/`--port` in the dev script, `PORT=` env in the dev script, or a config file read. If none of those resolve, the framework default is used:\n\n| Framework dependency | Default port |\n| --- | --- |\n| `next` | 3000 |\n| `vite` | 5173 |\n| `@remix-run/dev`, `@remix-run/serve` | 3000 |\n| `astro` | 4321 |\n| `nuxt`, `nuxt3` | 3000 |\n| `@sveltejs/kit` | 5173 |\n| `@angular/core` | 4200 |\n| `@nestjs/core` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | none (must be explicit in dev script) |\n\n### Server Actions allowedOrigins audit\n\nFor Next.js apps, Supbuddy reads your `next.config.{ts,mts,js,mjs,cjs}` and extracts the hosts in `experimental.serverActions.allowedOrigins`. If a mapped subdomain is missing from that list, the project's **warnings chip** flags `next.config: N origins missing`; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the **Apps** tab (the chip's \"open apps\" jump) where the affected app shows the warning with a **Fix** button.\n\nThe Fix button opens a dialog with a paste-ready snippet and an **Apply…** button: click it to see a unified diff of the change Supbuddy will make to your `next.config`, then **Confirm & write** to apply it. Supbuddy handles the four common config shapes (existing `allowedOrigins` array, existing `serverActions` block without it, existing `experimental` block without `serverActions`, or no `experimental` at all). After write, Supbuddy rescans the project so the warning disappears immediately. Restart your dev server for the change to take effect; Next.js does not hot-reload `next.config`.\n\n### Vite allowedHosts audit\n\nFor Vite apps, Supbuddy reads your `vite.config.{ts,mts,cts,js,mjs,cjs}` and extracts `server.allowedHosts`. If a mapped host isn't covered, the **warnings chip** flags `vite: N hosts blocked`; Vite's dev server otherwise rejects proxied requests for unknown hosts with `Blocked request. This host (\"…\") is not allowed.` (403). A `.your-project.local` entry counts as covering every subdomain, so an existing wildcard suffix doesn't trigger a false warning.\n\nLike the Next.js audit, the affected app's **Fix** button on the **Apps** tab opens a dialog with a paste-ready snippet and an **Apply…** button that previews a unified diff and writes `server.allowedHosts` into your `vite.config` (handling an existing `allowedHosts` array, an existing `server` block without it, or no `server` block at all; `allowedHosts: true` is left untouched). After write, Supbuddy rescans so the warning clears. Restart your dev server for the change to take effect; Vite does not hot-reload `vite.config`.\n\n## MCP setup (AI agents)\n\nSupbuddy ships a built-in MCP server on `http://127.0.0.1:9877/mcp` with static Bearer-token auth. Five clients have one-click install; any other MCP-compatible tool can be configured manually with the same URL + token.\n\nOpen **Settings → MCP → Add client**, pick the client kind, and Supbuddy generates a token, edits the client's config file, and backs up the original (`<file>.supbuddy-backup` next to it).\n\n### Auto-install paths\n\n| Client | Config file | Transport |\n| --- | --- | --- |\n| Claude Code | `~/.claude.json` (user) or `<project>/.mcp.json` (project) | HTTP |\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | stdio shim via `npx -y @supbuddy/mcp@latest` |\n| Cursor | `~/.cursor/mcp.json` (user) or `<project>/.cursor/mcp.json` (project) | HTTP |\n| Codex CLI | `~/.codex/config.toml` (adds an `[mcp_servers.supbuddy]` block) | HTTP |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` | HTTP |\n\n### MCP tool surface\n\nThe MCP server has full read and write access:\n\n- Read tools (`list_mappings`, `list_projects`, `get_health`, `get_compose_status`, `list_pending_vm_migrations`, etc.), with env values and request bodies included.\n- `get_client_capabilities` and `request_scope_elevation` (scope discovery + user-approved grant).\n- `read_env_file`, `tail_request_logs`, `watch_audit_log`.\n- Write tools: `create_mapping`, `delete_mapping` (soft-delete), `register_project`, `update_project`, `set_supabase_config_path`, `start_proxy`, `start_supabase`, `stop_supabase`, `restart_supabase`, `switch_isolation`, `migrate_vm_to_thin`, `finish_vm_migration`, `start_compose`, `stop_compose`, `restart_compose`, `scaffold_addons`, `seed_addons`, `write_env_file`, `copy_env_var`, `write_supabase_config`.\n- Scripts tools (`list_scripts`, `start_script`, `stop_script`, `restart_script`, `bookmark_script`, `tail_script_logs`); see *Scripts MCP tools* below.\n- Extended Supabase tools: `init_supabase`, `validate_supabase_config`, `list_supabase_backups`, `restore_supabase_backup`, `cancel_supabase_start`, `force_recreate_supabase`, `restart_supabase_container`, `get_supabase_analytics`, `set_supabase_analytics`.\n- Bundle (export/import a project's full config): `export_bundle`, `import_bundle`, `validate_bundle`.\n- Connection / env-target workflow: `preview_connection`, `get_env_targets`, `diff_env`, `apply_env`, `write_connection`, `test_connection`, `dismiss_connection_drift`.\n- Host & network tools: bundled-runtime trust (`get_trust_status`, `install_trust`, `remove_trust`, `detect_trust_tools`, `test_trust`), Tailscale (`get_tailscale_status`, `set_tailscale_key`, `remove_tailscale_key`, `test_tailscale`), DNS (`get_dns_status`), CA (`uninstall_ca`), and port-forwarding (`get_port_forwarding_status`, `set_port_forwarding`, `reload_port_forwarding`).\n- `tail_service_logs`: streams a Compose/add-on service's container logs over SSE (like `tail_request_logs` but for container stdout/stderr).\n- `watch_supabase`: streams a project's live Supabase start/stop/restart progress over SSE: operation status, image-pull/service snapshots, and (for VM projects) raw log lines. Backs `supbuddy supabase start --follow`.\n- Multiple MCP clients can connect simultaneously. The same MCP-HTTP surface backs the headless **CLI** (see *Command-line interface* below).\n\n### Scopes: discovery & self-service elevation\n\nEach MCP client holds a set of **scopes** (`read`, `log_tail`, `mappings`, `projects`, `services`, `config`, `system`, `apply`) chosen when it's added. A tool call that needs a scope the client lacks fails with `scope_denied`, whose payload now carries a `user_message` and `details.remediation` pointing at the fix.\n\n- `get_client_capabilities` ( `{ tool? }` ) returns the calling client's `granted_scopes` and `available_scopes`. Pass a `tool` name to get `{ required_scope, required_feature, can_call, reason? }` so an agent can pre-flight a call instead of probing by hitting `scope_denied`.\n- `request_scope_elevation` ( `{ scopes: [...] }` ) asks the **user** to grant the named scopes. Supbuddy shows a blocking approval dialog; on approval the scopes are added to the client. Already-granted scopes short-circuit without a prompt.\n\nYou can also review and edit any client's scopes from the GUI: **Settings → MCP → Clients** lists each client's granted scopes inline and exposes a **Scopes** button that opens the same scope editor used when adding a client.\n\n### Registering a project via MCP\n\n`register_project` takes a `root_path` (required), an optional `label`, `auto_scan` (default `true`), and an optional `isolation` (`'thin'` or `'host'`). It registers the project the same way the GUI's \"Add project\" flow does:\n\n- Derives a base domain as `<slug>.<defaultTld>` from the label (or the folder name), e.g. `staffhub.test`.\n- Records both the project `path` and `rootPath` so the project is visible to the proxy, scans, and file tools alike.\n- Scans the folder (unless `auto_scan: false`) for apps, services, scripts, and package manager.\n- Creates per-app subdomain mappings from the discovered apps (e.g. `site.staffhub.test → :3400`), derives the host service subdomains (`api.`, `studio.`, …), and reloads Caddy.\n- **Defaults to `thin` isolation**: the project gets its own loopback IP so its dev servers keep canonical ports (`:3000`) with no cross-project collisions — run them with `supbuddy run -- <dev command>`. The one exception: if the project's Supabase stack is **already running on the host outside Supbuddy**, registration keeps it on `host` (switching would rewrite its `config.toml` ports and orphan the running stack). Pass `isolation: 'host'` to opt out explicitly, or `isolation: 'thin'` to skip the detection and force thin.\n\nThe response includes an `isolation_note` explaining which mode was chosen and why — agents should read it instead of assuming.\n\n### Switching isolation over MCP\n\n`switch_isolation` ( `{ project_id, target_mode: 'host' | 'thin', auto_start? }` ) moves an existing project between **host** and **thin** mode. To-thin writes the per-project port block and `project_id` into `supabase/config.toml` and (unless `auto_start: false`) starts Supabase; to-host restores the original `config.toml` and stops that project's stack. It runs in the background and returns `{ started: true }`; poll `get_project` (`isolation`) for the current mode.\n\nA project can also be patched with `update_project`: its `patch` accepts `name`, `enabled`, `domain`, and `isolation` (it intentionally does **not** accept `path`/`rootPath`). Note that patching `isolation` only flips the flag; use `switch_isolation` to actually provision/tear down the port assignment.\n\n### Legacy VM migration over MCP\n\nFor projects still on the retired Isolated (VM) mode, three tools handle the one-way migration to Thin:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode, with their current `vmState` and migration readiness.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration. It dumps Postgres data from the VM, starts a fresh Thin stack, restores the dump, and row-count-verifies before signalling completion. Returns `{ started: true }`; poll `get_project` (`migrationState`) for progress.\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after verification passes. Errors if called before the verify step completes.\n\n### Repointing a project's Supabase config\n\n`set_supabase_config_path` ( `{ project_id, supabase_path }` ) switches which `supabase/config.toml` a project uses, for monorepos that carry more than one (e.g. a repo-root config and an app-level one). `supabase_path` is the project-relative directory **containing** the `supabase/` folder (`\".\"` for the repo root, e.g. `\"apps/getnightowls\"`). It persists the path, re-derives `supabaseProjectId` from the new config, and re-scans services. The previous stack's Docker volume is **left intact** (not deleted), so the switch is reversible; the response reports it under `orphaned_previous_stack`.\n\n### Moving a secret between env files\n\n`copy_env_var` ( `{ source_path, source_key, target_path, target_key? }` ) relocates a single variable from one env file to another (e.g. a value put in an app's `.env.local` that the stack actually injects from the repo-root `.env.local`). The value is read and written entirely inside the worker (it **never crosses the MCP boundary** and never appears in the audit log), so an agent can move a secret without it being printed. `target_key` defaults to `source_key`.\n\n### Plan / apply for destructive tools\n\nTools that delete or mutate state (`delete_mapping`, `delete_project`, `write_env_file`, etc.) return a *plan* with a preview. The MCP client (or you, in the Activity panel) explicitly calls `apply` with the `plan_id` to execute. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days.\n\n## Add-on Compose services\n\nA project can declare **extra** Docker Compose services that Supbuddy discovers, merges, runs, health-checks, and tails alongside the managed stack: a Redis cache, a worker queue, a search engine, etc. Add-on services run on the host's shared Docker daemon in both `host` and `thin` isolation, with no extra setup needed.\n\n### Declaration files & merge precedence\n\nSupbuddy looks for up to three Compose fragments in the project and merges them, later wins:\n\n1. `docker-compose.yml`: your base Compose file.\n2. `docker-compose.override.yml`: your own override, honored if present (standard Compose convention).\n3. `supbuddy.addons.yml`: Supbuddy-owned add-on fragment.\n\nAll present fragments are passed explicitly, e.g. `docker compose -f docker-compose.yml -f docker-compose.override.yml -f supbuddy.addons.yml --project-name <pinned> …`. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project's default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.\n\n### `supbuddy.addons.yml` format\n\nA valid Compose fragment (a standard `services:` map) plus an optional Supbuddy-only `x-supbuddy:` extension block. A plain `docker compose up` ignores `x-supbuddy:`, so the file stays usable without Supbuddy. Today `x-supbuddy` supports a one-shot **seed** step:\n\n```yaml\nservices:\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\nx-supbuddy:\n seed:\n service: redis\n command: [\"redis-cli\", \"ping\"] # explicit argv, runs once after services are healthy\n runOnce: true\n```\n\nThe seed step runs **once** after the add-on services are up and healthy. It's idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the `seed_addons` MCP tool.\n\n### MCP tools\n\n- `scaffold_addons` ( `{ project_id }` ): scope `config`. Creates a starter `supbuddy.addons.yml` if the project doesn't have one. Never clobbers an existing file.\n- `seed_addons` ( `{ project_id, force? }` ): scope `services`. Runs the declared `x-supbuddy.seed` step. Idempotent unless `force: true`.\n- `tail_service_logs` ( `{ project_id, service }` ): scope `log_tail`. Streams a Compose/add-on service's container logs over SSE (like `tail_request_logs`, but for container stdout/stderr).\n- `watch_supabase` ( `{ project_id }` ): scope `log_tail`. Streams a project's live Supabase start/stop/restart progress over SSE: `operation` (status + message), `progress` (image-pull/service snapshots), and `log` (raw lines, VM projects). The stream ends on a terminal status. Backs `supbuddy supabase start --follow`.\n\n### Scripts MCP tools\n\nScripts detected in a project (e.g. `dev`, `build`, `test`) are controllable over MCP:\n\n- `list_scripts` ( `{ project_id }` ): scope `read`. Returns all detected scripts with their current status and bookmark state.\n- `start_script` ( `{ project_id, script }` ): scope `services`. Starts the named script process.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process.\n- `bookmark_script` ( `{ project_id, script, bookmarked }` ): scope `services`. Pins (`bookmarked: true`) or unpins a script in the Quick Access group.\n- `tail_script_logs` ( `{ project_id, script }` ): scope `log_tail`. Streams the named script's stdout/stderr over SSE.\n\n### `get_compose_status` shape\n\n`get_compose_status` ( `{ project_id }` ) returns live per-service status, not just whether Compose is installed:\n\n```json\n{\n \"project_id\": \"…\",\n \"compose_installed\": true,\n \"running\": true,\n \"services\": [\n { \"name\": \"redis\", \"status\": \"running\", \"health\": \"healthy\", \"ports\": [\"6379:6379\"], \"image\": \"redis:7-alpine\", \"container_id\": \"…\", \"source\": \"addons\" }\n ]\n}\n```\n\nEach service's `source` is one of `base` | `override` | `addons`, telling you which fragment declared it.\n\n## Per-project AI context sync\n\nEach project has a **Context sync: AI tools** panel, accessible via the **AI Tools** tab in the project card, that writes a project-scoped briefing to disk so AI agents working in that repo see your live mappings, services, and isolation state without having to ask. Files written:\n\n- `.supbuddy/`: `README.md`, `mappings.md`, `services.md`, `project.md`, `mcp.md`, `do-not.md`, `docs.md`. The full live snapshot, regenerated on each sync.\n- `AGENTS.md` and `CLAUDE.md`: a small managed block prepended (or updated in place) telling the agent which project this is and pointing it at `.supbuddy/`.\n- Editor skill files when detected: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.github/copilot-instructions.md`, `.idea/supbuddy.md`.\n- `.gitignore` managed block, ignoring: `.supbuddy/meta.json` (volatile sync state), `*.supbuddy-backup-*` (rollback snapshots), and the per-editor skill files that are written **locally** (see scope below). The rest of `.supbuddy/` is intended to be committed; `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are also kept committable since you may have hand-written content there alongside Supbuddy's managed block.\n\n### Global vs. local scope\n\nThe per-editor skill files are generic Supbuddy-owned pointers (\"this is a Supbuddy project: read `.supbuddy/`, prefer the MCP tools\"). For editors that expose a **Supbuddy-owned global location**, Supbuddy writes that pointer **once, machine-wide** instead of copying it into every project, so it isn't duplicated across all your repos. Project-specific data always stays local in `.supbuddy/`.\n\n- **Claude Code** → one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** → `~/.cursor/skills/supbuddy/SKILL.md`. The global skill self-scopes: it only acts when the working directory has a `.supbuddy/` folder, and resolves the active project from that folder's `meta.json`.\n- All other targets (`windsurf`, `continue`, the `AGENTS.md`/`CLAUDE.md`/Copilot managed blocks, JetBrains) stay **local**: their \"global\" files are shared user files, so Supbuddy won't overwrite them.\n- Each target has a **scope** setting: `auto` (default: global for the Claude/Cursor skills, local for everything else), `global`, `local` (force per-project, useful if you commit the file for teammates), or `off`. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does **not** auto-remove these global files; delete them manually from `~/.claude/skills/supbuddy/` and `~/.cursor/skills/supbuddy/` if needed.\n- The always-loaded `CLAUDE.md`/`AGENTS.md` managed block stays local as a safety net so agents stay aware even if the on-demand global skill doesn't auto-activate.\n\nSync modes per project:\n\n- **Auto**: Supbuddy regenerates the files whenever mappings, services, or project state change.\n- **Manual only**: files are only written when you click **Sync now** (or use the tray's *Sync AI context for all projects*).\n- **Off**: nothing is written.\n\nThe collapsed header shows an at-a-glance status pill: mode (`auto` / `manual` / `off`), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn't present) appear greyed out in the **Detected targets** list inside the panel.\n\n## Command-line interface (CLI)\n\nEverything the desktop app can do is also driveable headlessly from a terminal, with no GUI window. The CLI runs a **daemon** (the same worker process the GUI uses: Caddy proxy, DNS, Supabase/Compose lifecycle, MCP-HTTP) and a set of commands that attach to it over the local MCP-HTTP port. This is for SSH sessions, CI, `tmux`/server boxes, and scripting.\n\nThe binary is `supbuddy`, with a short alias `sup`. Run `supbuddy help` for the full usage list.\n\nYou can install the CLI on its own, without the desktop app:\n\n```bash\nnpx supbuddy@latest # asks to install the CLI globally (supbuddy + sup)\n```\n\nThat command does nothing on its own except offer to put `supbuddy` and `sup` on your PATH. The CLI runs independently of the desktop app, so you can add the app later (or never). On a Mac the app installs the same two commands for you.\n\n### The daemon\n\n```bash\nsupbuddy daemon --detach # start the worker in the background\nsupbuddy status # daemon + proxy health\nsupbuddy stop # graceful shutdown\n```\n\n`--detach` backgrounds the daemon and prints its pid + ports. Foreground `supbuddy daemon` runs it attached (Ctrl-C shuts it down cleanly). On start the daemon writes a discovery file, `daemon.json` (mode `0600`), into the shared state dir holding its pid, the Socket.IO port, the MCP-HTTP port, and a control token; every other command reads it to find and authenticate to the daemon, so you never pass ports or tokens by hand. Only one daemon may run per state dir; a second `daemon` start is refused.\n\nThe CLI and the desktop app **share one state dir** (`~/Library/Application Support/Supbuddy/`), so they manage the same projects, mappings, and settings. They must not run two workers against it at once: if you launch the desktop app while a CLI daemon is running, the app detects it and offers to **stop the daemon and continue** or **quit**. It never forks a competing worker (which would corrupt `state.json`).\n\n### Run on login (service)\n\n```bash\nsupbuddy service install # start-on-login (launchd on macOS, systemd-user on Linux)\nsupbuddy service status\nsupbuddy service uninstall\n```\n\n### Commands\n\nAll app surfaces have a command. Names follow `supbuddy <module> <action> [args] [--flags]`. The main groups:\n\n| Group | Examples |\n| --- | --- |\n| Dev launcher | `run [--print] -- <dev command>` — on a Thin project, binds the dev server to the project's loopback IP (from `.supbuddy/meta.json`) so it keeps its canonical port (e.g. `supbuddy run -- next dev` stays on `:3000`) |\n| Health / proxy | `status`, `proxy status\\|start\\|stop\\|restart` |\n| Mappings | `map ls\\|add\\|get\\|set\\|enable\\|disable\\|rm\\|restore` |\n| Projects | `project ls\\|add\\|get\\|scan\\|set\\|enable\\|disable\\|rm\\|restore\\|env\\|refresh-context` |\n| Supabase | `supabase start\\|stop\\|restart\\|status <proj>` (add `--follow` to stream live progress), `supabase config apply <proj> <file>` |\n| Compose | `compose up\\|down\\|restart\\|status\\|logs <proj> [svcs]` |\n| Scripts | `scripts ls\\|start\\|stop\\|restart\\|logs\\|bookmark <proj> [script]` |\n| Isolation | `isolation switch <proj> <host\\|thin>`, `isolation pending-migrations`, `migrate start\\|finish <uuid>` |\n| Certificates | `ca status\\|install\\|uninstall` |\n| Env files | `env copy <src> <key> <target>`, `env write <path> <K=V>…` |\n| Settings | `settings get`, `settings set --json <patch>` |\n| MCP | `mcp add [<agent>]` (register Supbuddy into a coding agent: interactive, or `--write`/`--print`/`--prompt`), `mcp ls`, `mcp revoke <id>`, `mcp approvals apply\\|cancel <id>` |\n| Host / network | `connect`, `trust`, `tailscale`, `dns`, `pf` (port-forwarding) |\n| Logs | `logs requests [-f]`, `logs audit [-f]`, `logs get <id>` |\n| Account | `account`, `caps`, `addons scaffold\\|seed <proj>` |\n| Dashboard | `tui` (alias `dash`) |\n\nGlobal flags: `--json` (machine-readable output), `--yes` (skip confirmations), `--quiet`, `--url`/`--token` (attach to a specific/remote daemon instead of auto-discovery), `--state-dir` (override the shared dir), `--timeout`, and `-f`/`--follow` for streaming log commands and live `supabase start|stop|restart` progress.\n\nDestructive operations go through the same **plan → apply** gate as MCP (see *Plan / apply for destructive tools*); the CLI's control token is granted auto-apply, so they execute directly.\n\n### Live dashboard (TUI)\n\n```bash\nsupbuddy tui # or: sup dash\n```\n\n`supbuddy tui` opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project's isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press `r` to refresh, `q` to quit. It needs a running daemon (`supbuddy daemon --detach`); if none is found it tells you so.\n\n## Settings reference\n\nOpen Settings via the gear icon top-right or by clicking the tray icon → Open Dashboard → gear. Five tabs.\n\n### General\n\n- **Theme**: dark or light.\n- **Auto-start at login**: registers Supbuddy as a macOS login item. Default: on.\n- **Default TLD**: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default: `test`.\n- **Default isolation**: `host` or `thin` for newly added projects. Default: `thin` (per-project loopback IP; apps keep canonical ports like `:3000`). MCP registration additionally keeps a project on `host` when its Supabase stack is already running on the host outside Supbuddy.\n- **Auto-subdomain mapping**: when on, services and apps detected during a project scan get mappings created automatically. Default: on.\n- **Bundled-runtime trust**: installs Supbuddy's local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, …) actually read. These apps don't consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to `*.test` with `unable to get local issuer certificate`. Default: prompted on first launch when one of those tools is detected.\n - **macOS**: writes `~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plist` and calls `launchctl setenv NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE` so GUI-launched apps inherit the right vars at process-start time.\n - **Linux**: writes `~/.config/environment.d/supbuddy-ca.conf` (read by systemd-aware user sessions on GNOME/KDE/Sway/etc.).\n - **Windows**: per-user `setx NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE` to `HKCU\\Environment`.\n - All three env vars point at `~/Library/Application Support/Supbuddy/ca-bundle/current.crt` (or the platform-equivalent), a *cumulative* concatenated PEM Supbuddy maintains. When Caddy rotates its root (yearly today, sometimes more), Supbuddy appends the new root automatically; long-running TLS contexts holding the old root keep working until the process restarts.\n - **Test trust**: runs an in-process HTTPS request against the first available `*.test` mapping with the same env vars set, to verify end-to-end without relaunching anything.\n - **Conflict refusal**: if `NODE_EXTRA_CA_CERTS` is already set to something else (corporate proxy, Zscaler), Supbuddy refuses to overwrite and surfaces the conflicting path. You can override with the explicit prompt that pops up on Install.\n - **Quit and relaunch your AI tools** after install: the env var only takes effect for *newly-launched* processes.\n\n### Network\n\n- **HTTP port**: default 8080.\n- **HTTPS port**: default 8443.\n- **DNS port**: default 5353.\n- **Port forwarding**: when on, adds a `pfctl` rule mapping 80→HTTP port and 443→HTTPS port. Asks for sudo once.\n- **LAN sharing**: binds Caddy to `0.0.0.0` + starts mDNS responder.\n- **Tailscale**: paste a tailnet API key to enable split-DNS push.\n- **Install / Uninstall CA**: installs Caddy's root cert into your Keychain. Uninstall is not yet implemented (manual remove via Keychain Access).\n\n### Storage\n\nTrash retention (per-kind), volume sizes, image-cache controls.\n\n### MCP\n\n- **Clients**: list of connected clients. Each row has a **⋯** actions menu: install, edit scopes, set-primary, rotate token, revoke.\n- **Activity**: audit log with Apply/Cancel/Undo on plan rows.\n- **Trash**: soft-deleted mappings and projects, restorable for 7 days.\n- Settings: server `enabled`, `port` (default 9877), `audit_cap` (default 5000), `trash_ttl_days` (default 7).\n\n### AI Skills\n\nInstall Supbuddy's agent **skill at the user level** (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a **master on/off** plus an **autosync** toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.\n\n- **Who can install at user level**: only agents whose global file Supbuddy fully **owns** and that **self-scope** (act only when the working directory has a `.supbuddy/`): **Claude Code** (`~/.claude/skills/supbuddy/SKILL.md`) and **Cursor** (`~/.cursor/skills/supbuddy/SKILL.md`). The install is reference-counted under a synthetic `__user__` ref so it persists independent of any project and is never pruned by the boot reconcile.\n- **Master ↔ project**: the AI Skills tab is the **master** (user-level). To commit a skill into a specific repo, use that project's **AI Tools** tab and set the target to **Project** (the old `local` scope, which writes into the repo for teammates); **User** there means the master install covers it.\n- Agents whose global file holds *your own* content (Claude `CLAUDE.md`, Codex `AGENTS.md`, Copilot, Windsurf, Continue, JetBrains) are **project-level only**: a machine-wide write there could clobber your config, so they're injected per-project instead.\n\n## Tray menu\n\nThe macOS menu bar tray icon opens a menu with:\n\n- **Status: …**: current proxy state (running / idle).\n- **DNS Active (:5353)**: shown when proxy is running.\n- **LAN Sharing (\\<ip\\>)**: shown when LAN sharing is on.\n- **Tailscale (\\<ip\\>)**: shown when Tailscale is connected.\n- **Start Proxy / Stop Proxy**: opens the dashboard.\n- **Projects**: each project opens a submenu with **Apps** (click to open the mapped URL), **Supabase** services (status dot + open), and **Scripts** (your bookmarked scripts as a one-click **Start <name>** / **Stop <name>** toggle), plus **Restart Supabase**/**Restart services** and **Show in Supbuddy**.\n- **Open Dashboard**.\n- **Sync AI context for all projects**: runs the project-context sync engine for every registered project (writes `.supbuddy/`, `CLAUDE.md`, `AGENTS.md`, etc.).\n- **Show Logs**: reveals `main.log` in Finder.\n- **Check for Updates...**: manual update check (only enabled in packaged builds).\n- **Quit**.\n\n## File locations\n\nAll under `~/Library/Application Support/Supbuddy/` on macOS:\n\n- `main.log` + `main.log.1`: app logs (rotates at 2 MB).\n- `state.json`: persistent state (projects, mappings, settings, MCP clients, license).\n- `caddy-data/`: Caddy's data dir (PKI, autosaves, certs).\n- `caddy-data/caddy/pki/authorities/local/root.crt`: the local CA cert installed in your Keychain.\n- `ca-bundle/current.crt`: cumulative PEM containing every Caddy root that has ever been emitted. Used by **Bundled-runtime trust** as the target for `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`. Real file (not a symlink) so Bun-bundled CLIs read it correctly.\n- `ca-bundle/versioned/<sha>.crt`: per-root snapshots for forensics.\n- `Caddyfile`: generated reverse-proxy config.\n- `daemon.json`: written while a headless CLI daemon is running (pid, Socket.IO + MCP-HTTP ports, control token); `0600`, removed on shutdown. Used by `supbuddy` CLI commands to discover and authenticate to the daemon, and by the desktop app to detect a running CLI daemon at launch.\n- `certs/`: legacy CA from the pre-Caddy era (unused in current builds).\n\nMCP-specific:\n\n- MCP client tokens (file-backed secret, mode `0600`): `~/Library/Application Support/Supbuddy/secrets/mcp-<client-id>.secret`\n- MCP audit log: under `~/Library/Application Support/Supbuddy/`, capped at `audit_cap` entries (default 5000).\n\n## Troubleshooting\n\n### Browser shows \"Not secure\" or certificate warning\n\nThe Caddy CA is not trusted. Open **Settings → Network → Install Certificate**. macOS will prompt for your password. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* → System keychain → search for \"Caddy Local Authority\".\n\n### \"unable to get local issuer certificate\" / \"self signed certificate in certificate chain\" from Claude Code, Cursor, MCP servers, or other AI tools\n\nThese tools ship their own bundled JavaScript runtime (Bun, Electron, pkg-bundled Node) and ignore the system Keychain. Open **Settings → General → Bundled-runtime trust** and click **Install**. Then *fully quit and relaunch* the AI tool; the env vars only take effect for newly-launched processes. Verify with `launchctl getenv NODE_EXTRA_CA_CERTS` (macOS); it should print `~/Library/Application Support/Supbuddy/ca-bundle/current.crt`. If install is refused with a conflict warning, you already have `NODE_EXTRA_CA_CERTS` set (often a corporate proxy / Zscaler), so Supbuddy won't silently overwrite; use the override prompt or manually concatenate the two PEMs.\n\n### \"Docker is not running. Please start Docker Desktop.\"\n\nCompose and Supabase features need Docker. Open Docker Desktop and wait until the whale icon stops animating.\n\n### \"Docker Compose is not installed\"\n\nCompose v2 ships inside Docker Desktop. If you removed Docker Desktop and are using a standalone Docker daemon (e.g. Colima, Rancher), install compose: `brew install docker-compose`.\n\n### \"Leftover host containers\" / \"isolation drift\" warning on a project\n\nSupbuddy flags **isolation drift** when a project's running containers don't match its configured isolation mode, for example a **Host** project with a stale `thin`-mode stack still running, or a **Thin** project with leftover host-mode containers. Switching isolation modes doesn't tear down the old layer, so those containers linger, waste resources, and can shadow the project's real stack. The warning appears in the **warnings chip** next to the enable toggle (click it to see each item; it shows a spinner while Supbuddy re-checks), as an entry in the issues counter, and as a notice on the **Supabase** tab listing the exact containers and any data volumes.\n\n**Guided cleanup.** Open the Supabase tab → **Clean up leftovers…** to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to `…/Supbuddy/backups/<project>-<timestamp>/`). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.\n\nIf the leftover copy's data looks **newer** than the active one, the warning turns red; don't delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn't manage at all (e.g. a plain `supabase start`).\n\n### MCP client says \"Invalid OAuth error\" or \"JSON Parse error: Unexpected EOF\"\n\nThe MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in **Settings → MCP → the client's ⋯ menu → Rotate token**) or you're on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include `WWW-Authenticate: Bearer` so the client doesn't fall back to OAuth.\n\n### MCP token disappeared after app restart\n\nFixed in recent builds. If you're on an older version, regenerate the token. Root cause was that `addMcpClient` didn't trigger state persistence; the client was held in memory only.\n\n### Server Actions return 403 in a Next.js app behind Supbuddy\n\nNext.js's CSRF guard rejects POSTs whose Origin isn't in `experimental.serverActions.allowedOrigins`. Supbuddy detects this and flags it in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply…** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\n### Vite dev server returns \"Blocked request. This host is not allowed.\" (403)\n\nVite (v5+) rejects requests whose `Host` header isn't in `server.allowedHosts`, so a Vite app reached through a Supbuddy domain 403s until the host is allowed. Supbuddy detects this and flags `vite: N hosts blocked` in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply…** to preview a diff and write `server.allowedHosts` into your `vite.config` directly. **Restart the Vite dev server afterward**; Vite does not hot-reload its config. A single `.your-project.local` entry covers every subdomain.\n\n### Project shows a red \"PROXY ERROR\" banner: domain resolves but won't load\n\nAfter the proxy starts, Supbuddy runs an end-to-end reachability check: it resolves a project domain through the OS resolver and tries to connect to Caddy on the HTTPS port. If the name resolves but the connection fails, the project shows a red **PROXY ERROR** banner naming the likely cause (DNS, port-forwarding, or mDNS race) plus a recovery action.\n\nThe most common case: the domain resolves to `127.0.0.1` but port 443 won't connect because the elevated `pfctl` 443→8443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click **Retry**; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off→on instead. If LAN sharing is **off**, disregard any \"LAN sharing / Bonjour\" wording in the banner; the cause is the missing forward, not mDNS.\n\n### Port already in use (8080, 8443, 5353, 9877)\n\nDefault ports: HTTP 8080, HTTPS 8443, DNS 5353, MCP 9877. Change them in **Settings → Network** / **Settings → MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nQuit Supbuddy, then:\n\n```bash\n# Wipe app data (state, certs, Caddyfile, logs, MCP tokens under secrets/)\nrm -rf ~/Library/Application\\ Support/Supbuddy\n\n# Optional: remove the trusted CA\nsudo security delete-certificate -c \"Caddy Local Authority\" /Library/Keychains/System.keychain\n```\n\n## FAQ\n\n### Is Supbuddy free?\n\nYes. Supbuddy is free. Register as many projects and mappings as you want, with full HTTPS, full DNS, full Supabase isolation, and full read and write MCP access. There are no caps and no tiers.\n\n### Does Supbuddy send my data anywhere?\n\nNo. Caddy, the DNS server, and the MCP server all run locally on your Mac. The only outbound traffic is: Tailscale split-DNS push (only if you enabled it), auto-update checks (GitHub Releases), and Google Analytics on the marketing site (not the desktop app). The desktop app does not send telemetry.\n\n### Can I work offline?\n\nYes. The app works fully offline once the CA is trusted and projects are registered.\n\n### Linux / Windows support?\n\nThe desktop app is macOS-only in v2. The headless CLI and daemon also run on Linux, where `supbuddy service install` registers a `systemd-user` start-on-login unit (macOS uses `launchd`). Windows is not supported. A few desktop code paths (certutil, update-ca-certificates) anticipate other platforms but are not tested there.\n\n### Can I use my own TLD?\n\nYes. Set any TLD in **Settings → General → Default TLD**. Supbuddy installs `/etc/resolver/<project-domain>` files that tell macOS to query our DNS server for that project's domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.\n\n### What happens if I delete a project?\n\nThe project moves to the Trash (visible in **Settings → MCP → Trash**) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.\n\n### How do I uninstall Supbuddy?\n\n1. Quit the app.\n2. Drag **Supbuddy.app** from `/Applications` to the Trash.\n3. Optional cleanup: see \"Wipe everything and start over\" above.\n4. Optional: `sudo security delete-certificate -c \"Caddy Local Authority\" /Library/Keychains/System.keychain` to remove the trusted CA.\n\n### Where do I report a bug?\n\nEmail support with your version (visible at the bottom of the Settings popover) and the relevant lines from `~/Library/Application Support/Supbuddy/main.log`.\n";
39909
40047
  const HEADER = (filename) => `<!-- AUTO-GENERATED BY SUPBUDDY · DO NOT EDIT (file: ${filename}) -->
39910
40048
  `;
39911
40049
  function renderReadme(ctx) {
@@ -39962,14 +40100,43 @@ function renderProjectMd(ctx) {
39962
40100
  "|------|---------|------------|",
39963
40101
  ...scripts.map((s) => `| ${s.name} | \`${s.command ?? ""}\` | ${s.bookmarked ? "yes" : "no"} |`)
39964
40102
  ].join("\n");
40103
+ const loopbackIp2 = ctx.project.loopbackIp;
40104
+ const isThin = ctx.project.isolation === "thin";
40105
+ const devServerSection = isThin && loopbackIp2 ? `
40106
+ ## Dev servers (thin isolation)
40107
+
40108
+ This project has its own loopback IP: \`${loopbackIp2}\`. Apps bind it instead of
40109
+ 127.0.0.1, so **every dev server keeps its canonical port** (Next.js \`:3000\`,
40110
+ Vite \`:5173\`) with no cross-project collisions — a busy \`127.0.0.1:3000\` is
40111
+ NOT a reason to move this project to another port.
40112
+
40113
+ Start dev servers with:
40114
+
40115
+ \`\`\`sh
40116
+ supbuddy run -- <dev command> # e.g. supbuddy run -- next dev
40117
+ \`\`\`
40118
+
40119
+ It injects the right bind flag (\`-H ${loopbackIp2}\` for Next, \`--host\` for
40120
+ Vite) and ensures the loopback alias exists. Do not pin \`-p <other-port>\` to
40121
+ dodge a collision; point mappings at the canonical port instead.
40122
+ ` : `
40123
+ ## Dev servers (host isolation)
40124
+
40125
+ This project shares \`127.0.0.1\` with every other host-mode project, so dev
40126
+ server ports must not collide. Prefer switching to \`thin\` isolation
40127
+ (\`switch_isolation { target_mode: "thin" }\`) — it assigns a dedicated loopback
40128
+ IP so apps keep canonical ports like \`:3000\`. Keep \`host\` only when this
40129
+ project's Supabase runs on the host independently of Supbuddy.
40130
+ `;
39965
40131
  return HEADER(".supbuddy/project.md") + `# Project: ${ctx.project.name}
39966
40132
 
39967
40133
  - **Path:** \`${ctx.project.path}\`
39968
40134
  - **ID:** \`${ctx.project.id}\`
39969
- - **Isolation:** \`${ctx.project.isolation ?? "host"}\`
40135
+ - **Isolation:** \`${ctx.project.isolation ?? "host"}\`${isThin && loopbackIp2 ? `
40136
+ - **Loopback IP:** \`${loopbackIp2}\` (apps bind this; canonical ports stay free per project)` : ""}
39970
40137
  - **Package manager:** \`${ctx.project.packageManager ?? "unknown"}\`
39971
40138
  - **Enabled in Supbuddy:** ${ctx.project.enabled ? "yes" : "no"}
39972
-
40139
+ ${devServerSection}
39973
40140
  ## Apps
39974
40141
 
39975
40142
  ${appsSection}
@@ -40001,6 +40168,12 @@ ${rows}
40001
40168
 
40002
40169
  Use the MCP tool \`create_mapping(domain, port)\`, or open Supbuddy and edit
40003
40170
  in the Configure tab.
40171
+
40172
+ **Picking the port:** on a \`thin\` project, mappings route to the project's own
40173
+ loopback IP — target the app's **canonical** dev port (Next.js \`:3000\`, Vite
40174
+ \`:5173\`) and start it with \`supbuddy run -- <dev command>\`. Never scan for a
40175
+ "free" port to avoid another project's dev server; only \`host\`-mode projects
40176
+ share \`127.0.0.1\` and need unique ports.
40004
40177
  `;
40005
40178
  }
40006
40179
  function renderServicesMd(ctx) {
@@ -40082,10 +40255,22 @@ function renderDoNotMd(_ctx) {
40082
40255
  }
40083
40256
  const COMMON_SKILL_BODY = (ctx, kind) => {
40084
40257
  const link = `supbuddy://mcp/setup?project=${encodeURIComponent(ctx.project.id)}&kind=${kind}`;
40258
+ const loopbackIp2 = ctx.project.loopbackIp;
40259
+ const portsRule = ctx.project.isolation === "thin" && loopbackIp2 ? `**Dev servers & ports:** this project is on **thin** isolation with its own
40260
+ loopback IP \`${loopbackIp2}\`. Start dev servers with \`supbuddy run -- <dev command>\`
40261
+ (binds that IP) and keep every app on its **canonical** port (Next.js \`:3000\`,
40262
+ Vite \`:5173\`). A busy \`127.0.0.1:3000\` belongs to another project and is NOT a
40263
+ reason to pick a different port here.` : `**Dev servers & ports:** this project is on **host** isolation — it shares
40264
+ \`127.0.0.1\`, so ports can collide with other projects. Before picking a
40265
+ nonstandard port, prefer \`switch_isolation { target_mode: "thin" }\` (gives the
40266
+ project its own loopback IP so apps keep canonical ports like \`:3000\`); keep
40267
+ host only if this project's Supabase runs on the host outside Supbuddy.`;
40085
40268
  return `This project is managed by **Supbuddy**. Source of truth is \`.supbuddy/README.md\`
40086
40269
  and the linked files (project.md, mappings.md, services.md, mcp.md, do-not.md).
40087
40270
  Read those before answering questions about this project's URLs, ports, or services.
40088
40271
 
40272
+ ${portsRule}
40273
+
40089
40274
  **Prefer Supbuddy MCP tools over manual edits:**
40090
40275
  \`list_mappings\`, \`create_mapping\`, \`update_mapping\`, \`delete_mapping\`,
40091
40276
  \`get_supabase_status\`, \`get_compose_status\`, \`tail_request_logs\`,
@@ -40173,16 +40358,25 @@ Supbuddy-managed project.
40173
40358
 
40174
40359
  When a \`.supbuddy/\` directory IS present (use the NEAREST one at or above the cwd):
40175
40360
 
40176
- 1. Read \`.supbuddy/meta.json\` → \`project_id\`, \`isolation_mode\`, \`mcp.endpoint\`.
40177
- That identifies the active project; do not assume any other project.
40361
+ 1. Read \`.supbuddy/meta.json\` → \`project_id\`, \`isolation_mode\`, \`loopbackIp\`,
40362
+ \`mcp.endpoint\`. That identifies the active project; do not assume any other
40363
+ project.
40178
40364
  2. Read \`.supbuddy/README.md\` (index), then \`mappings.md\`, \`services.md\`,
40179
40365
  \`do-not.md\` as needed for this project's URLs, ports, and services.
40180
- 3. Prefer Supbuddy MCP tools (e.g. \`list_mappings\`, \`create_mapping\`,
40366
+ 3. **Dev servers & ports:** when \`isolation_mode\` is \`thin\`, the project has its
40367
+ own loopback IP (\`loopbackIp\`) — start dev servers with
40368
+ \`supbuddy run -- <dev command>\` and keep every app on its **canonical** port
40369
+ (Next.js \`:3000\`, Vite \`:5173\`). Never scan for a "free" port because
40370
+ \`127.0.0.1:3000\` is busy — that port belongs to another project's IP space.
40371
+ On \`host\` isolation ports DO collide; prefer switching the project to thin
40372
+ (\`switch_isolation { target_mode: "thin" }\`) unless its Supabase runs on the
40373
+ host outside Supbuddy.
40374
+ 4. Prefer Supbuddy MCP tools (e.g. \`list_mappings\`, \`create_mapping\`,
40181
40375
  \`get_supabase_status\`, \`get_compose_status\`, \`refresh_project_context\`) over
40182
40376
  manual edits. Verify reachability with \`get_account_info\`; if it fails, follow
40183
40377
  \`.supbuddy/mcp.md\` to (re)connect this client — the per-project MCP setup and
40184
40378
  deep link live THERE, not in this global skill.
40185
- 4. Do NOT edit \`/etc/hosts\`, the \`Caddyfile\`, or Supbuddy-managed certificates;
40379
+ 5. Do NOT edit \`/etc/hosts\`, the \`Caddyfile\`, or Supbuddy-managed certificates;
40186
40380
  do not bypass plan→apply for destructive MCP tools. Canonical list:
40187
40381
  \`.supbuddy/do-not.md\`.`;
40188
40382
  function renderGlobalSkill() {
@@ -40205,7 +40399,13 @@ function renderGlobalContent(slot) {
40205
40399
  return null;
40206
40400
  }
40207
40401
  }
40402
+ function buildProxiedUrl(domain, proxyState) {
40403
+ const httpsPort = proxyState.httpsPort ?? 8443;
40404
+ if (proxyState.portForwardingEnabled) return `https://${domain}`;
40405
+ return httpsPort === 443 ? `https://${domain}` : `https://${domain}:${httpsPort}`;
40406
+ }
40208
40407
  function renderMetaJson(ctx, checksums) {
40408
+ const urls = ctx.mappings.filter((m) => m.enabled && !(m.serviceRef ?? "").startsWith("supabase-")).map((m) => ({ domain: m.domain, port: m.port, url: buildProxiedUrl(m.domain, ctx.proxyState) }));
40209
40409
  return JSON.stringify({
40210
40410
  supbuddy_version: ctx.supbuddy_version,
40211
40411
  generated_at: ctx.generated_at,
@@ -40214,6 +40414,7 @@ function renderMetaJson(ctx, checksums) {
40214
40414
  // thin isolation: the per-project loopback IP its apps bind to. Surfaced so
40215
40415
  // the `supbuddy run` launcher can read it from cwd without an env override.
40216
40416
  ...ctx.project.loopbackIp ? { loopbackIp: ctx.project.loopbackIp } : {},
40417
+ ...urls.length ? { urls } : {},
40217
40418
  mcp: { endpoint: ctx.mcp_endpoint, running: ctx.mcp_running },
40218
40419
  checksums,
40219
40420
  managed_targets: Object.entries(ctx.detected_targets).filter(([, v2]) => v2).map(([k]) => k)
@@ -41235,11 +41436,18 @@ function timingSafeEqual(a, b) {
41235
41436
  if (ba.length !== bb.length) return false;
41236
41437
  return crypto.timingSafeEqual(ba, bb);
41237
41438
  }
41439
+ const TOOL_DESCRIPTIONS = {
41440
+ register_project: "Register a project with Supbuddy. Defaults to THIN isolation: the project gets its own loopback IP (127.0.0.N), so its dev servers keep their canonical ports — every project's Next.js app stays on :3000 with no cross-project collisions. NEVER move an app to a nonstandard port to dodge a busy :3000; start dev servers with `supbuddy run -- <dev command>` instead (it binds the project's loopback IP). The project registers on HOST isolation only when its Supabase stack is already running on the host outside Supbuddy (thin would rewrite its config.toml ports), or when you pass isolation:\"host\" explicitly. The response's `isolation_note` states which mode was chosen and why.",
41441
+ create_mapping: "Map a local domain (e.g. `web.acme.test`; the TLD follows your Default TLD setting) to a local port. For a THIN project the mapping automatically routes to the project's own loopback IP — point it at the app's CANONICAL dev port (Next.js :3000, Vite :5173) and run the app with `supbuddy run -- <dev command>`; do not scan for a free port. Only HOST-mode projects share 127.0.0.1 and need collision-free ports.",
41442
+ switch_isolation: "Switch a project between host and thin isolation. THIN (recommended, fast: loopback alias + proxy repoint) gives the project its own 127.0.0.N so apps keep canonical ports like :3000. Use HOST only when the project's Supabase runs on the host independently of Supbuddy. Runs in the background — poll get_project until `isolation` (and `loopbackIp` for thin) reflect the target.",
41443
+ import_bundle: 'Import a Supbuddy bundle (projects + mappings exported from another machine). The imported project lands on HOST isolation — after import, switch it to THIN (switch_isolation { target_mode: "thin" }) so its apps keep canonical ports (e.g. :3000) on a per-project loopback IP; keep HOST only if its Supabase runs on the host outside Supbuddy.',
41444
+ update_project: `Update a project's name, domain, or enabled state (patch.isolation:"host" exists only as a repair hatch for half-provisioned projects — it writes the field with NO side effects). To actually change isolation, use switch_isolation: thin is recommended (per-project loopback IP, apps keep canonical ports like :3000).`
41445
+ };
41238
41446
  function buildToolsList() {
41239
41447
  return ALL_MCP_TOOL_NAMES.map((tool) => {
41240
41448
  return {
41241
41449
  name: tool,
41242
- description: `Supbuddy ${tool} tool.`,
41450
+ description: TOOL_DESCRIPTIONS[tool] ?? `Supbuddy ${tool} tool.`,
41243
41451
  inputSchema: zodToJsonSchema(MCP_TOOL_SCHEMAS[tool])
41244
41452
  };
41245
41453
  });
@@ -45772,57 +45980,6 @@ function buildPfAnchor(input) {
45772
45980
  }
45773
45981
  return lines.join("\n") + "\n";
45774
45982
  }
45775
- const LOOPBACK_RANGE_START = 2;
45776
- const LOOPBACK_RANGE_END = 254;
45777
- const LOOPBACK_RE = /^127\.0\.0\.\d{1,3}$/;
45778
- function assertLoopbackIp(ip) {
45779
- if (!LOOPBACK_RE.test(ip)) throw new Error(`invalid loopback IP: ${ip}`);
45780
- }
45781
- function loopbackIp(n2) {
45782
- return `127.0.0.${n2}`;
45783
- }
45784
- function allocateLoopbackIp(taken) {
45785
- const used = new Set(taken);
45786
- for (let n2 = LOOPBACK_RANGE_START; n2 <= LOOPBACK_RANGE_END; n2++) {
45787
- const ip = loopbackIp(n2);
45788
- if (!used.has(ip)) return ip;
45789
- }
45790
- return null;
45791
- }
45792
- function buildAddAliasCommand(ip) {
45793
- assertLoopbackIp(ip);
45794
- return `ifconfig lo0 alias ${ip} up`;
45795
- }
45796
- function parseLo0Aliases(ifconfigOutput) {
45797
- const ips = [];
45798
- for (const line of ifconfigOutput.split("\n")) {
45799
- const m = line.match(/^\s*inet (\d+\.\d+\.\d+\.\d+)\b/);
45800
- if (m) ips.push(m[1]);
45801
- }
45802
- return ips;
45803
- }
45804
- function diffAliasesToAdd(want, have) {
45805
- const present = new Set(have);
45806
- return want.filter((ip) => !present.has(ip));
45807
- }
45808
- function buildEnsureAliasesCommand(missing) {
45809
- if (missing.length === 0) return null;
45810
- return missing.map(buildAddAliasCommand).join(" && ");
45811
- }
45812
- function reservedLoopbackIps(projects, exceptProjectId) {
45813
- return projects.filter((p2) => p2.id !== exceptProjectId && p2.loopbackIp).map((p2) => p2.loopbackIp);
45814
- }
45815
- function resolveLoopbackIp(project, projects) {
45816
- if (project.loopbackIp) return project.loopbackIp;
45817
- return allocateLoopbackIp(reservedLoopbackIps(projects, project.id));
45818
- }
45819
- function thinAliasIps(projects) {
45820
- const ips = projects.filter((p2) => p2.isolation === "thin" && p2.loopbackIp).map((p2) => p2.loopbackIp);
45821
- return Array.from(new Set(ips));
45822
- }
45823
- function appMappingUpstreamUpdates(mappings, projectId, loopbackIp2) {
45824
- return mappings.filter((m) => m.projectId === projectId && !m.autoGenerated).map((m) => ({ id: m.id, upstreamHost: loopbackIp2 ?? void 0 }));
45825
- }
45826
45983
  const execAsync$5 = require$$0$2.promisify(child_process.exec);
45827
45984
  const execFileAsync$2 = require$$0$2.promisify(child_process.execFile);
45828
45985
  const MIGRATE_DIR = path.join(os$1.tmpdir(), "supbuddy-migrate");
@@ -45975,6 +46132,9 @@ let impl = null;
45975
46132
  function registerIsolationSwitch(fn) {
45976
46133
  impl = fn;
45977
46134
  }
46135
+ function isIsolationSwitchReady() {
46136
+ return impl !== null;
46137
+ }
45978
46138
  async function runIsolationSwitch(projectId, targetMode, options = {}) {
45979
46139
  if (!impl) {
45980
46140
  throw new Error("Isolation switch is not available yet (worker still initializing)");
@@ -45983,6 +46143,7 @@ async function runIsolationSwitch(projectId, targetMode, options = {}) {
45983
46143
  }
45984
46144
  const isolationBridge = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
45985
46145
  __proto__: null,
46146
+ isIsolationSwitchReady,
45986
46147
  registerIsolationSwitch,
45987
46148
  runIsolationSwitch
45988
46149
  }, Symbol.toStringTag, { value: "Module" }));
@@ -47688,7 +47849,21 @@ async function performIsolationSwitch(projectId, targetMode, options = {}) {
47688
47849
  if (!project) throw new Error("Project not found");
47689
47850
  if (project.isolation === targetMode) return { success: true, noOp: true };
47690
47851
  if (targetMode === "thin" || project.isolation === "thin") {
47691
- return await switchThinIsolation(project, targetMode);
47852
+ try {
47853
+ const result = await switchThinIsolation(project, targetMode);
47854
+ if (useStore.getState().getProject(projectId)?.isolationError) {
47855
+ store2.updateProject(projectId, { isolationError: null });
47856
+ globalThis.io?.emit("projects-updated", useStore.getState().projects);
47857
+ }
47858
+ return result;
47859
+ } catch (err) {
47860
+ store2.updateProject(projectId, {
47861
+ isolationError: { message: err.message, targetMode, at: (/* @__PURE__ */ new Date()).toISOString() }
47862
+ });
47863
+ globalThis.io?.emit("projects-updated", useStore.getState().projects);
47864
+ console.error(`[isolation:switch] thin switch to ${targetMode} failed for ${projectId}:`, err.message);
47865
+ throw err;
47866
+ }
47692
47867
  }
47693
47868
  const direction = "to-host";
47694
47869
  const onProgress = (progress) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supbuddy",
3
- "version": "3.0.5",
3
+ "version": "3.0.7",
4
4
  "description": "Run multiple Supabase projects at once on custom local domains with HTTPS. A headless CLI and daemon (proxy, DNS, Supabase/Compose lifecycle, MCP) for macOS and Linux.",
5
5
  "keywords": [
6
6
  "supabase",