supbuddy 3.1.0 → 3.1.2

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
@@ -9255,11 +9255,11 @@ var require_mime_types = __commonJS({
9255
9255
  }
9256
9256
  return exts[0];
9257
9257
  }
9258
- function lookup(path36) {
9259
- if (!path36 || typeof path36 !== "string") {
9258
+ function lookup(path37) {
9259
+ if (!path37 || typeof path37 !== "string") {
9260
9260
  return false;
9261
9261
  }
9262
- var extension2 = extname("x." + path36).toLowerCase().substr(1);
9262
+ var extension2 = extname("x." + path37).toLowerCase().substr(1);
9263
9263
  if (!extension2) {
9264
9264
  return false;
9265
9265
  }
@@ -12660,11 +12660,11 @@ var require_server = __commonJS({
12660
12660
  * @protected
12661
12661
  */
12662
12662
  _computePath(options) {
12663
- let path36 = (options.path || "/engine.io").replace(/\/$/, "");
12663
+ let path37 = (options.path || "/engine.io").replace(/\/$/, "");
12664
12664
  if (options.addTrailingSlash !== false) {
12665
- path36 += "/";
12665
+ path37 += "/";
12666
12666
  }
12667
- return path36;
12667
+ return path37;
12668
12668
  }
12669
12669
  /**
12670
12670
  * Returns a list of available transports for upgrade given a certain transport.
@@ -13180,10 +13180,10 @@ var require_server = __commonJS({
13180
13180
  * @param {Object} options
13181
13181
  */
13182
13182
  attach(server, options = {}) {
13183
- const path36 = this._computePath(options);
13183
+ const path37 = this._computePath(options);
13184
13184
  const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1e3;
13185
13185
  function check(req) {
13186
- return path36 === req.url.slice(0, path36.length);
13186
+ return path37 === req.url.slice(0, path37.length);
13187
13187
  }
13188
13188
  const listeners = server.listeners("request").slice(0);
13189
13189
  server.removeAllListeners("request");
@@ -13191,7 +13191,7 @@ var require_server = __commonJS({
13191
13191
  server.on("listening", this.init.bind(this));
13192
13192
  server.on("request", (req, res) => {
13193
13193
  if (check(req)) {
13194
- debug('intercepting request for path "%s"', path36);
13194
+ debug('intercepting request for path "%s"', path37);
13195
13195
  this.handleRequest(req, res);
13196
13196
  } else {
13197
13197
  let i = 0;
@@ -14031,8 +14031,8 @@ var require_userver = __commonJS({
14031
14031
  * @param options
14032
14032
  */
14033
14033
  attach(app, options = {}) {
14034
- const path36 = this._computePath(options);
14035
- app.any(path36, this.handleRequest.bind(this)).ws(path36, {
14034
+ const path37 = this._computePath(options);
14035
+ app.any(path37, this.handleRequest.bind(this)).ws(path37, {
14036
14036
  compression: options.compression,
14037
14037
  idleTimeout: options.idleTimeout,
14038
14038
  maxBackpressure: options.maxBackpressure,
@@ -18461,7 +18461,7 @@ var require_dist2 = __commonJS({
18461
18461
  var zlib_1 = __require("zlib");
18462
18462
  var accepts = require_accepts();
18463
18463
  var stream_1 = __require("stream");
18464
- var path36 = __require("path");
18464
+ var path37 = __require("path");
18465
18465
  var engine_io_1 = require_engine_io();
18466
18466
  var client_1 = require_client();
18467
18467
  var events_1 = __require("events");
@@ -18656,7 +18656,7 @@ var require_dist2 = __commonJS({
18656
18656
  res.writeHeader("cache-control", "public, max-age=0");
18657
18657
  res.writeHeader("content-type", "application/" + (isMap ? "json" : "javascript") + "; charset=utf-8");
18658
18658
  res.writeHeader("etag", expectedEtag);
18659
- const filepath = path36.join(__dirname, "../client-dist/", filename);
18659
+ const filepath = path37.join(__dirname, "../client-dist/", filename);
18660
18660
  (0, uws_1.serveFile)(res, filepath);
18661
18661
  });
18662
18662
  }
@@ -18738,7 +18738,7 @@ var require_dist2 = __commonJS({
18738
18738
  * @private
18739
18739
  */
18740
18740
  static sendFile(filename, req, res) {
18741
- const readStream = (0, fs_1.createReadStream)(path36.join(__dirname, "../client-dist/", filename));
18741
+ const readStream = (0, fs_1.createReadStream)(path37.join(__dirname, "../client-dist/", filename));
18742
18742
  const encoding = accepts(req).encodings(["br", "gzip", "deflate"]);
18743
18743
  const onError = (err) => {
18744
18744
  if (err) {
@@ -19323,6 +19323,7 @@ var init_relay_server = __esm({
19323
19323
 
19324
19324
  // src/host-cli.ts
19325
19325
  import fs2 from "fs/promises";
19326
+ import path2 from "path";
19326
19327
  function hostBridgeFailSafeReply(message) {
19327
19328
  const t = message?.type;
19328
19329
  switch (t) {
@@ -19339,6 +19340,31 @@ function hostBridgeFailSafeReply(message) {
19339
19340
  return null;
19340
19341
  }
19341
19342
  }
19343
+ function shQuote(value) {
19344
+ return `'${value.replace(/'/g, `'\\''`)}'`;
19345
+ }
19346
+ function pfctlReloadCommand(errFile) {
19347
+ const q = shQuote(errFile);
19348
+ return [
19349
+ "pfctl -e 2>/dev/null || true",
19350
+ `pfctl -f /etc/pf.conf 2>${q}`,
19351
+ "status=$?",
19352
+ `chmod 644 ${q} 2>/dev/null || true`,
19353
+ "exit $status"
19354
+ ].join("\n");
19355
+ }
19356
+ async function takePfctlError(errFile) {
19357
+ try {
19358
+ const raw = await fs2.readFile(errFile, "utf-8");
19359
+ const text = raw.trim().replace(/\s*\n\s*/g, "; ");
19360
+ return text.length > 500 ? `${text.slice(0, 500)}\u2026` : text;
19361
+ } catch {
19362
+ return "";
19363
+ } finally {
19364
+ await fs2.unlink(errFile).catch(() => {
19365
+ });
19366
+ }
19367
+ }
19342
19368
  function attachCliHostBridge(child, opts) {
19343
19369
  const { stateDir, assumeYes, quiet, delegate } = opts;
19344
19370
  const bridge = createCliHostBridge({ stateDir, assumeYes, quiet });
@@ -19390,15 +19416,20 @@ function attachCliHostBridge(child, opts) {
19390
19416
  return;
19391
19417
  }
19392
19418
  if (t === "port-forwarding:reload") {
19419
+ const errFile = path2.join(stateDir, `pfctl-reload-${process.pid}-${Date.now()}.err`);
19420
+ await fs2.mkdir(stateDir, { recursive: true }).catch(() => {
19421
+ });
19393
19422
  const result = await bridge.elevate({
19394
- command: 'pfctl -e 2>/dev/null || true; pfctl -f /etc/pf.conf 2>&1; echo "pfctl reloaded"',
19423
+ command: pfctlReloadCommand(errFile),
19395
19424
  prompt: "Supbuddy needs to activate port forwarding (redirect ports 80/443)."
19396
19425
  });
19426
+ const pfctlError = await takePfctlError(errFile);
19397
19427
  if (result.success) {
19398
19428
  send({ type: "port-forwarding:reload:response", data: { success: true } });
19399
19429
  } else {
19400
- console.error("[daemon] port-forwarding:reload failed:", result.error);
19401
- send({ type: "port-forwarding:reload:response", data: { success: false, error: result.error } });
19430
+ const error = pfctlError ? `pfctl failed to load /etc/pf.conf: ${pfctlError}` : result.error;
19431
+ console.error("[daemon] port-forwarding:reload failed:", error);
19432
+ send({ type: "port-forwarding:reload:response", data: { success: false, error } });
19402
19433
  }
19403
19434
  return;
19404
19435
  }
@@ -19489,13 +19520,13 @@ var init_host_cli2 = __esm({
19489
19520
 
19490
19521
  // src/daemon-json.ts
19491
19522
  import fs3 from "fs/promises";
19492
- import path2 from "path";
19523
+ import path3 from "path";
19493
19524
  import { execFileSync } from "child_process";
19494
19525
  function isDaemonInfo(o) {
19495
19526
  return !!o && typeof o === "object" && typeof o.pid === "number" && typeof o.socketPort === "number" && typeof o.mcpPort === "number" && typeof o.stateDir === "string" && typeof o.bootToken === "string" && typeof o.startedAt === "string" && typeof o.relayPort === "number" && (o.version === void 0 || typeof o.version === "string");
19496
19527
  }
19497
19528
  function daemonJsonPath(stateDir) {
19498
- return path2.join(stateDir, "daemon.json");
19529
+ return path3.join(stateDir, "daemon.json");
19499
19530
  }
19500
19531
  async function writeDaemonInfo(info) {
19501
19532
  await fs3.mkdir(info.stateDir, { recursive: true }).catch(() => {
@@ -19548,10 +19579,10 @@ var init_daemon_json = __esm({
19548
19579
 
19549
19580
  // src/daemon-logging.ts
19550
19581
  import fs4 from "fs";
19551
- import path3 from "path";
19582
+ import path4 from "path";
19552
19583
  function daemonLogPaths(stateDir) {
19553
- const logDir = path3.join(stateDir, "logs");
19554
- return { logDir, out: path3.join(logDir, "daemon.log"), err: path3.join(logDir, "daemon-error.log") };
19584
+ const logDir = path4.join(stateDir, "logs");
19585
+ return { logDir, out: path4.join(logDir, "daemon.log"), err: path4.join(logDir, "daemon-error.log") };
19555
19586
  }
19556
19587
  function openDaemonLogFds(stateDir) {
19557
19588
  const { logDir, out, err } = daemonLogPaths(stateDir);
@@ -19706,14 +19737,14 @@ var init_network_monitor = __esm({
19706
19737
 
19707
19738
  // src/state-dir.ts
19708
19739
  import os3 from "os";
19709
- import path4 from "path";
19740
+ import path5 from "path";
19710
19741
  function defaultStateDir() {
19711
19742
  const override = process.env.SUPBUDDY_STATE_DIR;
19712
19743
  if (override) return override;
19713
19744
  const home = os3.homedir();
19714
- if (process.platform === "darwin") return path4.join(home, "Library", "Application Support", "Supbuddy");
19715
- if (process.platform === "win32") return path4.join(process.env.APPDATA ?? path4.join(home, "AppData", "Roaming"), "Supbuddy");
19716
- return path4.join(process.env.XDG_CONFIG_HOME ?? path4.join(home, ".config"), "Supbuddy");
19745
+ if (process.platform === "darwin") return path5.join(home, "Library", "Application Support", "Supbuddy");
19746
+ if (process.platform === "win32") return path5.join(process.env.APPDATA ?? path5.join(home, "AppData", "Roaming"), "Supbuddy");
19747
+ return path5.join(process.env.XDG_CONFIG_HOME ?? path5.join(home, ".config"), "Supbuddy");
19717
19748
  }
19718
19749
  var init_state_dir = __esm({
19719
19750
  "src/state-dir.ts"() {
@@ -19723,7 +19754,7 @@ var init_state_dir = __esm({
19723
19754
 
19724
19755
  // src/caddy-resolve.ts
19725
19756
  import { createRequire } from "module";
19726
- import path5 from "path";
19757
+ import path6 from "path";
19727
19758
  function caddyPackageName(platform = process.platform, arch = process.arch) {
19728
19759
  if (platform === "darwin") {
19729
19760
  return arch === "arm64" ? "@supbuddy/caddy-darwin-arm64" : "@supbuddy/caddy-darwin-amd64";
@@ -19738,7 +19769,7 @@ function resolvePackagedCaddyDir(deps = {}) {
19738
19769
  if (!pkgName) return null;
19739
19770
  const resolve = deps.resolve ?? ((id) => requireFromHere.resolve(id));
19740
19771
  try {
19741
- return path5.dirname(resolve(`${pkgName}/package.json`));
19772
+ return path6.dirname(resolve(`${pkgName}/package.json`));
19742
19773
  } catch {
19743
19774
  return null;
19744
19775
  }
@@ -20505,8 +20536,8 @@ var init_parseUtil = __esm({
20505
20536
  init_errors();
20506
20537
  init_en();
20507
20538
  makeIssue = (params) => {
20508
- const { data, path: path36, errorMaps, issueData } = params;
20509
- const fullPath = [...path36, ...issueData.path || []];
20539
+ const { data, path: path37, errorMaps, issueData } = params;
20540
+ const fullPath = [...path37, ...issueData.path || []];
20510
20541
  const fullIssue = {
20511
20542
  ...issueData,
20512
20543
  path: fullPath
@@ -20817,11 +20848,11 @@ var init_types2 = __esm({
20817
20848
  init_parseUtil();
20818
20849
  init_util();
20819
20850
  ParseInputLazyPath = class {
20820
- constructor(parent, value, path36, key) {
20851
+ constructor(parent, value, path37, key) {
20821
20852
  this._cachedPath = [];
20822
20853
  this.parent = parent;
20823
20854
  this.data = value;
20824
- this._path = path36;
20855
+ this._path = path37;
20825
20856
  this._key = key;
20826
20857
  }
20827
20858
  get path() {
@@ -25026,7 +25057,7 @@ var init_shared = __esm({
25026
25057
  import { spawn as spawn2 } from "child_process";
25027
25058
  import { fileURLToPath } from "url";
25028
25059
  import fs5 from "fs";
25029
- import path6 from "path";
25060
+ import path7 from "path";
25030
25061
  function resolveWorkerEntry(deps = {}) {
25031
25062
  const binDir = deps.binDir ?? __dirname2;
25032
25063
  const repoRoot = deps.repoRoot ?? REPO_ROOT;
@@ -25034,15 +25065,15 @@ function resolveWorkerEntry(deps = {}) {
25034
25065
  const exists = deps.exists ?? fs5.existsSync;
25035
25066
  const bundle = env3.SUPBUDDY_WORKER_BUNDLE;
25036
25067
  if (bundle) {
25037
- return { cmd: process.execPath, args: [bundle], workerEntry: bundle, cwd: path6.dirname(bundle), mode: "host" };
25068
+ return { cmd: process.execPath, args: [bundle], workerEntry: bundle, cwd: path7.dirname(bundle), mode: "host" };
25038
25069
  }
25039
- const pkgWorker = path6.join(binDir, "daemon", "worker.cjs");
25070
+ const pkgWorker = path7.join(binDir, "daemon", "worker.cjs");
25040
25071
  if (exists(pkgWorker)) {
25041
- return { cmd: process.execPath, args: [pkgWorker], workerEntry: pkgWorker, cwd: path6.dirname(pkgWorker), mode: "npm" };
25072
+ return { cmd: process.execPath, args: [pkgWorker], workerEntry: pkgWorker, cwd: path7.dirname(pkgWorker), mode: "npm" };
25042
25073
  }
25043
- const bootstrap = path6.join(repoRoot, "apps", "cli", "src", "worker-bootstrap.ts");
25074
+ const bootstrap = path7.join(repoRoot, "apps", "cli", "src", "worker-bootstrap.ts");
25044
25075
  if (exists(bootstrap)) {
25045
- const workerEntry = path6.join(repoRoot, "packages", "core", "index.ts");
25076
+ const workerEntry = path7.join(repoRoot, "packages", "core", "index.ts");
25046
25077
  return { cmd: process.execPath, args: ["--import", "tsx", bootstrap], workerEntry, cwd: repoRoot, mode: "dev" };
25047
25078
  }
25048
25079
  throw new WorkerUnavailableError();
@@ -25212,7 +25243,7 @@ async function runDaemonDetached(opts = {}) {
25212
25243
  }
25213
25244
  const entry = process.argv[1];
25214
25245
  const isTsEntry = entry.endsWith(".ts");
25215
- const execPath = isTsEntry ? path6.join(REPO_ROOT, "node_modules", ".bin", "tsx") : process.execPath;
25246
+ const execPath = isTsEntry ? path7.join(REPO_ROOT, "node_modules", ".bin", "tsx") : process.execPath;
25216
25247
  const args = [entry, "daemon"];
25217
25248
  if (opts.stateDir) args.push(`--state-dir=${opts.stateDir}`);
25218
25249
  if (opts.workerPort) args.push(`--worker-port=${opts.workerPort}`);
@@ -25252,8 +25283,8 @@ var init_daemon = __esm({
25252
25283
  init_state_dir();
25253
25284
  init_caddy_resolve();
25254
25285
  init_shared();
25255
- __dirname2 = path6.dirname(fileURLToPath(import.meta.url));
25256
- REPO_ROOT = path6.resolve(__dirname2, "..", "..", "..");
25286
+ __dirname2 = path7.dirname(fileURLToPath(import.meta.url));
25287
+ REPO_ROOT = path7.resolve(__dirname2, "..", "..", "..");
25257
25288
  WorkerUnavailableError = class extends Error {
25258
25289
  constructor() {
25259
25290
  super(
@@ -25555,13 +25586,13 @@ var init_types3 = __esm({
25555
25586
  import fs6 from "fs/promises";
25556
25587
  import { exec } from "child_process";
25557
25588
  import { promisify } from "util";
25558
- import path7 from "path";
25589
+ import path8 from "path";
25559
25590
  function backupDirFor(appSupportDir, stamp) {
25560
- return path7.join(appSupportDir, "backups", `reset-${stamp}`);
25591
+ return path8.join(appSupportDir, "backups", `reset-${stamp}`);
25561
25592
  }
25562
25593
  async function backupStateJson(dir, ctx) {
25563
- const src = path7.join(ctx.appSupportDir, "state.json");
25564
- const dest = path7.join(dir, "state.json");
25594
+ const src = path8.join(ctx.appSupportDir, "state.json");
25595
+ const dest = path8.join(dir, "state.json");
25565
25596
  await ctx.fs.mkdir(dir);
25566
25597
  try {
25567
25598
  await ctx.fs.copyFile(src, dest);
@@ -25582,7 +25613,7 @@ async function backupDockerVolume(vol, dir, ctx) {
25582
25613
  );
25583
25614
  }
25584
25615
  await ctx.fs.mkdir(dir);
25585
- const out = path7.join(dir, `${vol}.tar.gz`);
25616
+ const out = path8.join(dir, `${vol}.tar.gz`);
25586
25617
  await ctx.archive(
25587
25618
  `docker run --rm -v "${vol}:/src:ro" -v "${dir}:/backup" alpine tar czf "/backup/${vol}.tar.gz" -C /src .`
25588
25619
  );
@@ -25605,7 +25636,7 @@ async function backupDockerVolume(vol, dir, ctx) {
25605
25636
  }
25606
25637
  async function writeManifest(dir, actions, ctx, results, meta) {
25607
25638
  await ctx.fs.mkdir(dir);
25608
- const file = path7.join(dir, "manifest.json");
25639
+ const file = path8.join(dir, "manifest.json");
25609
25640
  const body = {
25610
25641
  ...meta,
25611
25642
  version: 1,
@@ -27590,7 +27621,7 @@ __export(store_exports, {
27590
27621
  });
27591
27622
  import crypto from "crypto";
27592
27623
  import fs7 from "fs/promises";
27593
- import path8 from "path";
27624
+ import path9 from "path";
27594
27625
  import os5 from "os";
27595
27626
  function coerceProjectName(name, fallback = "Untitled project") {
27596
27627
  if (typeof name === "string") return name;
@@ -27612,24 +27643,24 @@ async function getAppSupportDir() {
27612
27643
  return override;
27613
27644
  }
27614
27645
  if (process.env.VITEST || process.env.NODE_ENV === "test") {
27615
- const testDir = path8.join(os5.tmpdir(), `supbuddy-vitest-${process.pid}`);
27646
+ const testDir = path9.join(os5.tmpdir(), `supbuddy-vitest-${process.pid}`);
27616
27647
  await fs7.mkdir(testDir, { recursive: true });
27617
27648
  return testDir;
27618
27649
  }
27619
27650
  const platform = process.platform;
27620
27651
  let userDataPath;
27621
27652
  if (platform === "darwin") {
27622
- userDataPath = path8.join(os5.homedir(), "Library", "Application Support", "Supbuddy");
27653
+ userDataPath = path9.join(os5.homedir(), "Library", "Application Support", "Supbuddy");
27623
27654
  } else if (platform === "win32") {
27624
- userDataPath = path8.join(process.env.APPDATA || path8.join(os5.homedir(), "AppData", "Roaming"), "Supbuddy");
27655
+ userDataPath = path9.join(process.env.APPDATA || path9.join(os5.homedir(), "AppData", "Roaming"), "Supbuddy");
27625
27656
  } else {
27626
- userDataPath = path8.join(process.env.XDG_CONFIG_HOME || path8.join(os5.homedir(), ".config"), "Supbuddy");
27657
+ userDataPath = path9.join(process.env.XDG_CONFIG_HOME || path9.join(os5.homedir(), ".config"), "Supbuddy");
27627
27658
  }
27628
27659
  await fs7.mkdir(userDataPath, { recursive: true });
27629
27660
  return userDataPath;
27630
27661
  }
27631
27662
  async function getStorePath() {
27632
- return path8.join(await getAppSupportDir(), "state.json");
27663
+ return path9.join(await getAppSupportDir(), "state.json");
27633
27664
  }
27634
27665
  async function persistState() {
27635
27666
  try {
@@ -28501,24 +28532,24 @@ var init_dind_manager = __esm({
28501
28532
  import { promisify as promisify3 } from "util";
28502
28533
  import { execFile as execFile2, spawn as spawn4 } from "child_process";
28503
28534
  import fs8 from "fs/promises";
28504
- import path9 from "path";
28535
+ import path10 from "path";
28505
28536
  import os6 from "os";
28506
28537
  function getCacheDir() {
28507
28538
  const override = process.env.SUPBUDDY_STATE_DIR;
28508
- if (override) return path9.join(override, "image-cache");
28539
+ if (override) return path10.join(override, "image-cache");
28509
28540
  if (process.env.VITEST || process.env.NODE_ENV === "test") {
28510
- return path9.join(os6.tmpdir(), `supbuddy-vitest-${process.pid}`, "image-cache");
28541
+ return path10.join(os6.tmpdir(), `supbuddy-vitest-${process.pid}`, "image-cache");
28511
28542
  }
28512
28543
  const platform = process.platform;
28513
28544
  let base;
28514
28545
  if (platform === "darwin") {
28515
- base = path9.join(os6.homedir(), "Library", "Application Support", "Supbuddy");
28546
+ base = path10.join(os6.homedir(), "Library", "Application Support", "Supbuddy");
28516
28547
  } else if (platform === "win32") {
28517
- base = path9.join(process.env.APPDATA || path9.join(os6.homedir(), "AppData", "Roaming"), "Supbuddy");
28548
+ base = path10.join(process.env.APPDATA || path10.join(os6.homedir(), "AppData", "Roaming"), "Supbuddy");
28518
28549
  } else {
28519
- base = path9.join(process.env.XDG_CONFIG_HOME || path9.join(os6.homedir(), ".config"), "Supbuddy");
28550
+ base = path10.join(process.env.XDG_CONFIG_HOME || path10.join(os6.homedir(), ".config"), "Supbuddy");
28520
28551
  }
28521
- return path9.join(base, "image-cache");
28552
+ return path10.join(base, "image-cache");
28522
28553
  }
28523
28554
  async function hasImageCache() {
28524
28555
  try {
@@ -28535,7 +28566,7 @@ async function getImageCacheSize() {
28535
28566
  let total = 0;
28536
28567
  for (const f of files) {
28537
28568
  if (!f.endsWith(".tar")) continue;
28538
- const stat2 = await fs8.stat(path9.join(dir, f));
28569
+ const stat2 = await fs8.stat(path10.join(dir, f));
28539
28570
  total += stat2.size;
28540
28571
  }
28541
28572
  return total;
@@ -31630,6 +31661,18 @@ var init_compose_scanner = __esm({
31630
31661
 
31631
31662
  // ../../packages/core/caddy-ca-manager.ts
31632
31663
  import fs9 from "fs/promises";
31664
+ async function readSystemKeychainCaddyFingerprints() {
31665
+ const { execSync: execSync3 } = await import("child_process");
31666
+ try {
31667
+ const out = execSync3(
31668
+ 'security find-certificate -a -c "Caddy Local Authority" -Z /Library/Keychains/System.keychain 2>&1',
31669
+ { encoding: "utf-8" }
31670
+ );
31671
+ return parseKeychainSha1Fingerprints(out);
31672
+ } catch {
31673
+ return [];
31674
+ }
31675
+ }
31633
31676
  function parseKeychainSha1Fingerprints(securityOutput) {
31634
31677
  return [...securityOutput.matchAll(/^SHA-1 hash:\s*([0-9A-F]+)\s*$/gim)].map(
31635
31678
  (m) => m[1].toUpperCase()
@@ -31639,17 +31682,7 @@ async function uninstallCaddyCA(elevate = defaultElevate) {
31639
31682
  const platform = process.platform;
31640
31683
  try {
31641
31684
  if (platform === "darwin") {
31642
- const { execSync: execSync3 } = await import("child_process");
31643
- let fingerprints = [];
31644
- try {
31645
- const check = execSync3(
31646
- 'security find-certificate -a -c "Caddy Local Authority" -Z /Library/Keychains/System.keychain 2>&1',
31647
- { encoding: "utf-8" }
31648
- );
31649
- fingerprints = parseKeychainSha1Fingerprints(check);
31650
- } catch {
31651
- fingerprints = [];
31652
- }
31685
+ const fingerprints = await readSystemKeychainCaddyFingerprints();
31653
31686
  if (fingerprints.length === 0) {
31654
31687
  return { success: true, message: "Caddy Local Authority certificate not found in keychain \u2014 nothing to remove." };
31655
31688
  }
@@ -31662,12 +31695,16 @@ async function uninstallCaddyCA(elevate = defaultElevate) {
31662
31695
  }
31663
31696
  return { success: true, message: "Caddy Local Authority removed from System keychain." };
31664
31697
  } else if (platform === "win32") {
31665
- return { success: true, message: "Caddy manages CA trust on Windows automatically. Restart your browser to pick up the change." };
31698
+ return {
31699
+ success: false,
31700
+ needsManual: true,
31701
+ error: "Automatic CA removal is not available on Windows yet.",
31702
+ message: 'Remove it manually: open certlm.msc \u2192 Trusted Root Certification Authorities \u2192 Certificates, delete every "Caddy Local Authority" entry, then fully quit and reopen your browser.'
31703
+ };
31666
31704
  } else {
31667
- const systemCaPath = "/usr/local/share/ca-certificates/caddy-root.crt";
31668
31705
  let present = false;
31669
31706
  try {
31670
- await fs9.access(systemCaPath);
31707
+ await fs9.access(LINUX_TRUST_PATH);
31671
31708
  present = true;
31672
31709
  } catch {
31673
31710
  present = false;
@@ -31676,7 +31713,7 @@ async function uninstallCaddyCA(elevate = defaultElevate) {
31676
31713
  return { success: true, message: "caddy-root.crt not found in /usr/local/share/ca-certificates \u2014 nothing to remove." };
31677
31714
  }
31678
31715
  const result = await elevate({
31679
- command: "rm -f /usr/local/share/ca-certificates/caddy-root.crt && update-ca-certificates --fresh",
31716
+ command: `rm -f ${LINUX_TRUST_PATH} && update-ca-certificates --fresh`,
31680
31717
  prompt: "Supbuddy needs administrator access to remove the Caddy CA from the system trust store."
31681
31718
  });
31682
31719
  if (!result.success) {
@@ -31689,7 +31726,7 @@ async function uninstallCaddyCA(elevate = defaultElevate) {
31689
31726
  return { success: false, error: error.message || "Failed to uninstall CA" };
31690
31727
  }
31691
31728
  }
31692
- var defaultElevate;
31729
+ var defaultElevate, LINUX_TRUST_PATH;
31693
31730
  var init_caddy_ca_manager = __esm({
31694
31731
  "../../packages/core/caddy-ca-manager.ts"() {
31695
31732
  "use strict";
@@ -31713,44 +31750,45 @@ var init_caddy_ca_manager = __esm({
31713
31750
  process.on("message", handler);
31714
31751
  });
31715
31752
  };
31753
+ LINUX_TRUST_PATH = "/usr/local/share/ca-certificates/caddy-root.crt";
31716
31754
  }
31717
31755
  });
31718
31756
 
31719
31757
  // ../../packages/core/bundled-runtime-trust.ts
31720
31758
  import fs10 from "fs/promises";
31721
31759
  import fsSync from "fs";
31722
- import path10 from "path";
31760
+ import path11 from "path";
31723
31761
  import os7 from "os";
31724
31762
  import { execFile as execFile4, execFileSync as execFileSync2 } from "child_process";
31725
31763
  import { promisify as promisify5 } from "util";
31726
31764
  function getSupbuddyDataDir() {
31727
31765
  const platform = process.platform;
31728
31766
  if (platform === "darwin") {
31729
- return path10.join(os7.homedir(), "Library", "Application Support", "Supbuddy");
31767
+ return path11.join(os7.homedir(), "Library", "Application Support", "Supbuddy");
31730
31768
  }
31731
31769
  if (platform === "win32") {
31732
- return path10.join(
31733
- process.env.APPDATA || path10.join(os7.homedir(), "AppData", "Roaming"),
31770
+ return path11.join(
31771
+ process.env.APPDATA || path11.join(os7.homedir(), "AppData", "Roaming"),
31734
31772
  "Supbuddy"
31735
31773
  );
31736
31774
  }
31737
- return path10.join(
31738
- process.env.XDG_CONFIG_HOME || path10.join(os7.homedir(), ".config"),
31775
+ return path11.join(
31776
+ process.env.XDG_CONFIG_HOME || path11.join(os7.homedir(), ".config"),
31739
31777
  "Supbuddy"
31740
31778
  );
31741
31779
  }
31742
31780
  function getBundleDir() {
31743
- return path10.join(getSupbuddyDataDir(), "ca-bundle");
31781
+ return path11.join(getSupbuddyDataDir(), "ca-bundle");
31744
31782
  }
31745
31783
  function getBundlePath() {
31746
- return path10.join(getBundleDir(), "current.crt");
31784
+ return path11.join(getBundleDir(), "current.crt");
31747
31785
  }
31748
31786
  function getPlistPath() {
31749
- return path10.join(os7.homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
31787
+ return path11.join(os7.homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
31750
31788
  }
31751
31789
  function getEnvironmentDPath() {
31752
- return path10.join(
31753
- process.env.XDG_CONFIG_HOME || path10.join(os7.homedir(), ".config"),
31790
+ return path11.join(
31791
+ process.env.XDG_CONFIG_HOME || path11.join(os7.homedir(), ".config"),
31754
31792
  "environment.d",
31755
31793
  "supbuddy-ca.conf"
31756
31794
  );
@@ -31794,7 +31832,7 @@ async function scanLegacyTrustAgents() {
31794
31832
  if (process.platform !== "darwin") return [];
31795
31833
  const managed = getPlistPath();
31796
31834
  const dirs = [
31797
- path10.join(os7.homedir(), "Library", "LaunchAgents"),
31835
+ path11.join(os7.homedir(), "Library", "LaunchAgents"),
31798
31836
  "/Library/LaunchAgents",
31799
31837
  "/Library/LaunchDaemons"
31800
31838
  ];
@@ -31808,7 +31846,7 @@ async function scanLegacyTrustAgents() {
31808
31846
  }
31809
31847
  for (const name of names) {
31810
31848
  if (!name.endsWith(".plist")) continue;
31811
- const full = path10.join(dir, name);
31849
+ const full = path11.join(dir, name);
31812
31850
  if (full === managed) continue;
31813
31851
  let content;
31814
31852
  try {
@@ -31829,7 +31867,7 @@ async function cleanupLegacyTrustAgents() {
31829
31867
  try {
31830
31868
  await fs10.copyFile(plistPath, `${plistPath}.supbuddy-backup`).catch(() => {
31831
31869
  });
31832
- const label = path10.basename(plistPath, ".plist");
31870
+ const label = path11.basename(plistPath, ".plist");
31833
31871
  const uid = process.getuid?.() ?? 501;
31834
31872
  await execFileP("/bin/launchctl", ["bootout", `gui/${uid}/${label}`]).catch(() => {
31835
31873
  });
@@ -32025,11 +32063,11 @@ var init_docker_watcher = __esm({
32025
32063
  import { exec as exec3 } from "child_process";
32026
32064
  import { promisify as promisify6 } from "util";
32027
32065
  import fs11 from "fs/promises";
32028
- import path11 from "path";
32066
+ import path12 from "path";
32029
32067
  async function tailscaleKeyPath() {
32030
- const dir = path11.join(await getAppSupportDir(), "secrets");
32068
+ const dir = path12.join(await getAppSupportDir(), "secrets");
32031
32069
  await fs11.mkdir(dir, { recursive: true, mode: 448 });
32032
- return path11.join(dir, "tailscale-api-key.secret");
32070
+ return path12.join(dir, "tailscale-api-key.secret");
32033
32071
  }
32034
32072
  async function persistTailscaleKey(key) {
32035
32073
  try {
@@ -34462,7 +34500,7 @@ var init_mapping_scope = __esm({
34462
34500
 
34463
34501
  // ../../packages/core/dns-platform.ts
34464
34502
  import fs12 from "fs/promises";
34465
- import path12 from "path";
34503
+ import path13 from "path";
34466
34504
  function getManagedDomainSuffixes() {
34467
34505
  const store = useStore2.getState();
34468
34506
  const suffixes = /* @__PURE__ */ new Set();
@@ -34481,7 +34519,7 @@ async function buildMacOsCleanupCommand() {
34481
34519
  const files = await fs12.readdir(resolverDir);
34482
34520
  const toRemove = [];
34483
34521
  for (const file of files) {
34484
- const filePath = path12.join(resolverDir, file);
34522
+ const filePath = path13.join(resolverDir, file);
34485
34523
  try {
34486
34524
  const content = await fs12.readFile(filePath, "utf-8");
34487
34525
  if (content.includes(SUPBUDDY_MARKER)) {
@@ -34555,7 +34593,7 @@ async function auditMacOsResolver(expected) {
34555
34593
  const present = [];
34556
34594
  for (const file of entries) {
34557
34595
  try {
34558
- const content = await fs12.readFile(path12.join(resolverDir, file), "utf-8");
34596
+ const content = await fs12.readFile(path13.join(resolverDir, file), "utf-8");
34559
34597
  if (content.includes(SUPBUDDY_MARKER)) present.push(file);
34560
34598
  } catch {
34561
34599
  }
@@ -36817,21 +36855,21 @@ var init_dns_server = __esm({
36817
36855
 
36818
36856
  // ../../packages/core/caddyfile-generator.ts
36819
36857
  import fs13 from "fs/promises";
36820
- import path13 from "path";
36858
+ import path14 from "path";
36821
36859
  import os8 from "os";
36822
36860
  async function getCaddyfileDir() {
36823
36861
  const platform = process.platform;
36824
36862
  let userDataPath;
36825
36863
  if (platform === "darwin") {
36826
- userDataPath = path13.join(os8.homedir(), "Library", "Application Support", "Supbuddy");
36864
+ userDataPath = path14.join(os8.homedir(), "Library", "Application Support", "Supbuddy");
36827
36865
  } else if (platform === "win32") {
36828
- userDataPath = path13.join(
36829
- process.env.APPDATA || path13.join(os8.homedir(), "AppData", "Roaming"),
36866
+ userDataPath = path14.join(
36867
+ process.env.APPDATA || path14.join(os8.homedir(), "AppData", "Roaming"),
36830
36868
  "Supbuddy"
36831
36869
  );
36832
36870
  } else {
36833
- userDataPath = path13.join(
36834
- process.env.XDG_CONFIG_HOME || path13.join(os8.homedir(), ".config"),
36871
+ userDataPath = path14.join(
36872
+ process.env.XDG_CONFIG_HOME || path14.join(os8.homedir(), ".config"),
36835
36873
  "Supbuddy"
36836
36874
  );
36837
36875
  }
@@ -36851,7 +36889,7 @@ async function writeFileAtomic(filePath, content) {
36851
36889
  }
36852
36890
  async function generateCaddyfile(mappings, settings, options = {}, validate) {
36853
36891
  const caddyfileDir = await getCaddyfileDir();
36854
- const caddyfilePath = path13.join(caddyfileDir, "Caddyfile");
36892
+ const caddyfilePath = path14.join(caddyfileDir, "Caddyfile");
36855
36893
  const caddyfileContent = buildCaddyfileContent(mappings, settings, options);
36856
36894
  if (validate) {
36857
36895
  const res = await validate(caddyfileContent);
@@ -36984,7 +37022,7 @@ function buildCaddyfileContent(mappings, settings, options = {}) {
36984
37022
  }
36985
37023
  async function getCaddyDataDir2() {
36986
37024
  const caddyfileDir = await getCaddyfileDir();
36987
- const dataDir = path13.join(caddyfileDir, "caddy-data");
37025
+ const dataDir = path14.join(caddyfileDir, "caddy-data");
36988
37026
  await fs13.mkdir(dataDir, { recursive: true });
36989
37027
  return dataDir;
36990
37028
  }
@@ -37041,9 +37079,9 @@ var init_env_utils = __esm({
37041
37079
  });
37042
37080
 
37043
37081
  // ../../packages/core/project-env.ts
37044
- import path14 from "path";
37082
+ import path15 from "path";
37045
37083
  async function resolveProjectEnv(projectPath) {
37046
- const all = await readEnvFileAsDict(path14.join(projectPath, ".env.local"));
37084
+ const all = await readEnvFileAsDict(path15.join(projectPath, ".env.local"));
37047
37085
  const out = {};
37048
37086
  for (const [k, v] of Object.entries(all)) {
37049
37087
  if (k.startsWith("SUPABASE_")) out[k] = v;
@@ -37313,8 +37351,8 @@ async function isSupabaseInitialized(projectPath) {
37313
37351
  const { promisify: promisify15 } = await import("util");
37314
37352
  const execAsync9 = promisify15(exec9);
37315
37353
  const fs33 = await import("fs/promises");
37316
- const path36 = await import("path");
37317
- const supabasePath = path36.join(projectPath, "supabase");
37354
+ const path37 = await import("path");
37355
+ const supabasePath = path37.join(projectPath, "supabase");
37318
37356
  await fs33.access(supabasePath);
37319
37357
  return true;
37320
37358
  } catch {
@@ -37338,8 +37376,8 @@ function parseFieldFromBlock(block, field, fallback) {
37338
37376
  async function parseConfigPort(projectPath, section, fallback, field = "port") {
37339
37377
  try {
37340
37378
  const fs33 = await import("fs/promises");
37341
- const path36 = await import("path");
37342
- const content = await fs33.readFile(path36.join(projectPath, "supabase", "config.toml"), "utf-8");
37379
+ const path37 = await import("path");
37380
+ const content = await fs33.readFile(path37.join(projectPath, "supabase", "config.toml"), "utf-8");
37343
37381
  const block = extractSectionBlock(content, section);
37344
37382
  if (!block) return fallback;
37345
37383
  return parseFieldFromBlock(block, field, fallback);
@@ -37349,10 +37387,10 @@ async function parseConfigPort(projectPath, section, fallback, field = "port") {
37349
37387
  }
37350
37388
  async function readSupabasePorts(projectPath) {
37351
37389
  const fs33 = await import("fs/promises");
37352
- const path36 = await import("path");
37390
+ const path37 = await import("path");
37353
37391
  let content = "";
37354
37392
  try {
37355
- content = await fs33.readFile(path36.join(projectPath, "supabase", "config.toml"), "utf-8");
37393
+ content = await fs33.readFile(path37.join(projectPath, "supabase", "config.toml"), "utf-8");
37356
37394
  } catch {
37357
37395
  return { db: 54322, api: 54321, studio: 54323, inbucket: 54324, shadow: 54320, pooler: 54329, smtp: 54325, pop3: 54326, analytics: 54327 };
37358
37396
  }
@@ -37694,10 +37732,10 @@ function takenSupabaseProjectIds(projects, excludeId) {
37694
37732
  }
37695
37733
  async function resolveSupaDir(project) {
37696
37734
  if (!project.path) return null;
37697
- const path36 = await import("path");
37735
+ const path37 = await import("path");
37698
37736
  const fs33 = await import("fs/promises");
37699
- const supaDir = project.supabasePath ? path36.join(project.path, project.supabasePath) : project.path;
37700
- const configPath = path36.join(supaDir, "supabase", "config.toml");
37737
+ const supaDir = project.supabasePath ? path37.join(project.path, project.supabasePath) : project.path;
37738
+ const configPath = path37.join(supaDir, "supabase", "config.toml");
37701
37739
  try {
37702
37740
  await fs33.access(configPath);
37703
37741
  } catch {
@@ -37864,14 +37902,14 @@ function buildL4AppConfig() {
37864
37902
  }
37865
37903
  return { servers };
37866
37904
  }
37867
- function adminRequest(method, path36, body) {
37905
+ function adminRequest(method, path37, body) {
37868
37906
  return new Promise((resolve, reject) => {
37869
37907
  const payload = body === void 0 ? void 0 : JSON.stringify(body);
37870
37908
  const req = http.request(
37871
37909
  {
37872
37910
  host: ADMIN_HOST,
37873
37911
  port: ADMIN_PORT,
37874
- path: path36,
37912
+ path: path37,
37875
37913
  method,
37876
37914
  headers: payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {},
37877
37915
  timeout: 5e3
@@ -38080,7 +38118,7 @@ var init_caddy_supervisor = __esm({
38080
38118
  import { spawn as spawn6, execFile as execFile5 } from "child_process";
38081
38119
  import { promisify as promisify8 } from "util";
38082
38120
  import { randomUUID } from "crypto";
38083
- import path15 from "path";
38121
+ import path16 from "path";
38084
38122
  import fs15 from "fs/promises";
38085
38123
  import nodeProcess2 from "process";
38086
38124
  function getCaddyBinaryPath() {
@@ -38096,18 +38134,18 @@ function getCaddyBinaryPath() {
38096
38134
  }
38097
38135
  const bundledBinDir = nodeProcess2.env.SUPBUDDY_BIN_DIR;
38098
38136
  if (bundledBinDir) {
38099
- return path15.join(bundledBinDir, binaryName);
38137
+ return path16.join(bundledBinDir, binaryName);
38100
38138
  }
38101
38139
  const isDev = process.env.NODE_ENV !== "production";
38102
38140
  if (isDev) {
38103
- return path15.join(process.cwd(), "resources", "bin", binaryName);
38141
+ return path16.join(process.cwd(), "resources", "bin", binaryName);
38104
38142
  } else {
38105
- return path15.join(process.resourcesPath, "bin", binaryName);
38143
+ return path16.join(process.resourcesPath, "bin", binaryName);
38106
38144
  }
38107
38145
  }
38108
38146
  async function reapStaleCaddyProcesses() {
38109
38147
  if (process.platform === "win32") return;
38110
- const binaryName = path15.basename(getCaddyBinaryPath());
38148
+ const binaryName = path16.basename(getCaddyBinaryPath());
38111
38149
  let survivors = await listCaddyPids(binaryName);
38112
38150
  for (const pid of survivors) {
38113
38151
  sendSignalIgnoringMissing(pid, "SIGTERM");
@@ -38200,7 +38238,7 @@ async function doStartCaddyServer() {
38200
38238
  ...process.env,
38201
38239
  HOME: homeDir,
38202
38240
  XDG_DATA_HOME: dataDir,
38203
- XDG_CONFIG_HOME: path15.dirname(caddyfilePath)
38241
+ XDG_CONFIG_HOME: path16.dirname(caddyfilePath)
38204
38242
  };
38205
38243
  caddyProcess = spawn6(binaryPath, ["run", "--config", caddyfilePath, "--adapter", "caddyfile"], {
38206
38244
  env: caddyEnv,
@@ -38374,7 +38412,7 @@ function isCaddyErrorLine(line) {
38374
38412
  async function validateCaddyfileContent(content) {
38375
38413
  const binaryPath = getCaddyBinaryPath();
38376
38414
  const dir = await getCaddyfileDir();
38377
- const tmpPath = path15.join(dir, `Caddyfile.validate.${process.pid}.${caddyValidateCounter++}.tmp`);
38415
+ const tmpPath = path16.join(dir, `Caddyfile.validate.${process.pid}.${caddyValidateCounter++}.tmp`);
38378
38416
  const execFileAsync6 = promisify8(execFile5);
38379
38417
  try {
38380
38418
  await fs15.writeFile(tmpPath, content, "utf-8");
@@ -38956,7 +38994,7 @@ var init_ca_and_trust = __esm({
38956
38994
  });
38957
38995
 
38958
38996
  // ../../packages/core/system-doctor/checks/orphan-mcp-secrets.ts
38959
- import path16 from "path";
38997
+ import path17 from "path";
38960
38998
  function secretKey(clientId) {
38961
38999
  return clientId.replace(/[^a-zA-Z0-9._-]/g, "_");
38962
39000
  }
@@ -38968,8 +39006,8 @@ function liveSecretKeys() {
38968
39006
  return new Set(clients.filter((c) => !c.revoked).map((c) => secretKey(c.id)));
38969
39007
  }
38970
39008
  function isRemovableSecret(p, dir, live) {
38971
- if (path16.dirname(p) !== dir) return false;
38972
- const m = SECRET_RE.exec(path16.basename(p));
39009
+ if (path17.dirname(p) !== dir) return false;
39010
+ const m = SECRET_RE.exec(path17.basename(p));
38973
39011
  return m !== null && !live.has(m[1]);
38974
39012
  }
38975
39013
  var SECRET_RE;
@@ -38983,7 +39021,7 @@ var init_orphan_mcp_secrets = __esm({
38983
39021
 
38984
39022
  // ../../packages/core/system-doctor/wipe/steps/revoked-mcp-secrets.ts
38985
39023
  import fs19 from "fs/promises";
38986
- import path17 from "path";
39024
+ import path18 from "path";
38987
39025
  var revokedMcpSecrets;
38988
39026
  var init_revoked_mcp_secrets = __esm({
38989
39027
  "../../packages/core/system-doctor/wipe/steps/revoked-mcp-secrets.ts"() {
@@ -38994,7 +39032,7 @@ var init_revoked_mcp_secrets = __esm({
38994
39032
  tiers: ["deep", "full"],
38995
39033
  destroysUserData: false,
38996
39034
  async build(ctx) {
38997
- const dir = path17.join(ctx.appSupportDir, "secrets");
39035
+ const dir = path18.join(ctx.appSupportDir, "secrets");
38998
39036
  let live;
38999
39037
  try {
39000
39038
  live = liveSecretKeys();
@@ -39007,11 +39045,11 @@ var init_revoked_mcp_secrets = __esm({
39007
39045
  } catch {
39008
39046
  return [];
39009
39047
  }
39010
- const targets = entries.filter((e) => e.isFile()).map((e) => path17.join(dir, e.name)).filter((p) => isRemovableSecret(p, dir, live));
39048
+ const targets = entries.filter((e) => e.isFile()).map((e) => path18.join(dir, e.name)).filter((p) => isRemovableSecret(p, dir, live));
39011
39049
  if (targets.length === 0) return [];
39012
39050
  return [
39013
39051
  {
39014
- label: `Delete ${targets.length} dead MCP token secret(s) from ${dir}: ${targets.map((p) => path17.basename(p)).join(", ")}`,
39052
+ label: `Delete ${targets.length} dead MCP token secret(s) from ${dir}: ${targets.map((p) => path18.basename(p)).join(", ")}`,
39015
39053
  destructive: true,
39016
39054
  run: async () => {
39017
39055
  const failures = [];
@@ -39145,7 +39183,7 @@ var init_orphan_dind = __esm({
39145
39183
  });
39146
39184
 
39147
39185
  // ../../packages/core/system-doctor/wipe/steps/tier3-targets.ts
39148
- import path18 from "path";
39186
+ import path19 from "path";
39149
39187
  function projectSnapshot() {
39150
39188
  return [...useStore2.getState().projects];
39151
39189
  }
@@ -39217,7 +39255,7 @@ async function dindTargets(ctx) {
39217
39255
  return targets;
39218
39256
  }
39219
39257
  async function requireArchive(ctx, vol) {
39220
- const archive = path18.join(backupDirFor(ctx.appSupportDir, ctx.stamp), `${vol}.tar.gz`);
39258
+ const archive = path19.join(backupDirFor(ctx.appSupportDir, ctx.stamp), `${vol}.tar.gz`);
39221
39259
  let size = -1;
39222
39260
  try {
39223
39261
  size = (await ctx.fs.stat(archive)).size;
@@ -39246,9 +39284,9 @@ var init_tier3_targets = __esm({
39246
39284
 
39247
39285
  // ../../packages/core/system-doctor/wipe/steps/backup-project-data.ts
39248
39286
  import fs20 from "fs/promises";
39249
- import path19 from "path";
39287
+ import path20 from "path";
39250
39288
  function dumpAction(p, container, dir, ctx) {
39251
- const dest = path19.join(dir, `supabase-${p.id}.pgc`);
39289
+ const dest = path20.join(dir, `supabase-${p.id}.pgc`);
39252
39290
  return {
39253
39291
  label: `Back up the Supabase database of "${p.name ?? p.id}" (pg_dump of ${container}) to ${dest}`,
39254
39292
  destructive: false,
@@ -39264,7 +39302,7 @@ function dumpAction(p, container, dir, ctx) {
39264
39302
  `Backup of "${p.name ?? p.id}" is truncated: copied ${size} of ${dump2.bytes} bytes to ${dest}`
39265
39303
  );
39266
39304
  }
39267
- await ctx.fs.writeFile(`${dest}.sha256`, `${dump2.sha256} ${path19.basename(dest)}
39305
+ await ctx.fs.writeFile(`${dest}.sha256`, `${dump2.sha256} ${path20.basename(dest)}
39268
39306
  `);
39269
39307
  } finally {
39270
39308
  await fs20.rm(dump2.dir, { recursive: true, force: true }).catch(() => {
@@ -39305,7 +39343,7 @@ var init_backup_project_data = __esm({
39305
39343
  }
39306
39344
  for (const vol of [...new Set(volumes)]) {
39307
39345
  actions.push({
39308
- label: `Archive docker volume '${vol}' to ${path19.join(dir, `${vol}.tar.gz`)} (may take several minutes)`,
39346
+ label: `Archive docker volume '${vol}' to ${path20.join(dir, `${vol}.tar.gz`)} (may take several minutes)`,
39309
39347
  destructive: false,
39310
39348
  // Never hand-rolled: backupDockerVolume prechecks the volume, archives on
39311
39349
  // the long-timeout THROWING seam and proves the result with `gzip -t`.
@@ -39322,7 +39360,7 @@ var init_backup_project_data = __esm({
39322
39360
 
39323
39361
  // ../../packages/core/project-context/detect.ts
39324
39362
  import fs21 from "fs/promises";
39325
- import path20 from "path";
39363
+ import path21 from "path";
39326
39364
  async function isDir(p) {
39327
39365
  try {
39328
39366
  const st = await fs21.stat(p);
@@ -39335,12 +39373,12 @@ async function detectTargets(projectPath) {
39335
39373
  return {
39336
39374
  agents_md: true,
39337
39375
  claude_md: true,
39338
- cursor: await isDir(path20.join(projectPath, ".cursor")),
39339
- claude_skills: await isDir(path20.join(projectPath, ".claude")),
39340
- windsurf: await isDir(path20.join(projectPath, ".codeium", "windsurf")),
39341
- continue: await isDir(path20.join(projectPath, ".continue")),
39342
- copilot: await isDir(path20.join(projectPath, ".github")),
39343
- jetbrains: await isDir(path20.join(projectPath, ".idea"))
39376
+ cursor: await isDir(path21.join(projectPath, ".cursor")),
39377
+ claude_skills: await isDir(path21.join(projectPath, ".claude")),
39378
+ windsurf: await isDir(path21.join(projectPath, ".codeium", "windsurf")),
39379
+ continue: await isDir(path21.join(projectPath, ".continue")),
39380
+ copilot: await isDir(path21.join(projectPath, ".github")),
39381
+ jetbrains: await isDir(path21.join(projectPath, ".idea"))
39344
39382
  };
39345
39383
  }
39346
39384
  var init_detect = __esm({
@@ -39354,7 +39392,7 @@ var DOCS_MARKDOWN;
39354
39392
  var init_docs_generated = __esm({
39355
39393
  "../../packages/core/project-context/docs.generated.ts"() {
39356
39394
  "use strict";
39357
- 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\nCaddy mints its local CA the first time it actually serves a site, so the cert only exists once you have **at least one enabled mapping and the proxy running** \u2014 an empty proxy never generates it. With that in place, open the app and click **Install** (the first-launch prompt, or **Settings \u2192 Network** later). Supbuddy adds the CA (Caddy's internal PKI at `~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt`) to your **System keychain** via `sudo security add-trusted-cert`; macOS asks for your password once. Caddy does **not** self-install trust (the generated Caddyfile sets `skip_install_trust`), so this button is what makes the padlock green \u2014 fully quit and reopen your browser afterward to pick it up. Every Supbuddy domain then gets HTTPS with no per-domain prompts or warnings. (On Windows the install is manual: Supbuddy shows the PowerShell `Import-Certificate \u2026 -CertStoreLocation Cert:\\LocalMachine\\Root` command to run as Administrator.)\n\nCaddy names its root by year, so each yearly rotation (or a data wipe) leaves a same-name root behind with a different key. On every Install, Supbuddy first removes any stale `Caddy Local Authority` roots whose fingerprint doesn't match the current one, then adds the current root \u2014 leftover mismatched roots otherwise make Firefox-family browsers fail with `SEC_ERROR_BAD_SIGNATURE`.\n\n**Firefox, Zen, and Brave keep their own certificate store** that Supbuddy can't reach (they don't consult the System keychain). After a CA change, either delete any stale `Caddy Local Authority` entries from the browser's own certificate manager and re-import the new root, or \u2014 on Firefox/Zen \u2014 set `security.enterprise_roots.enabled` to `true` in `about:config` so the browser reads the System keychain.\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 \u2192 General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings \u2192 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 \u2192 `api.<project>.test`\n- Supabase Studio \u2192 `studio.<project>.test`\n- Supabase Inbucket / Mailpit \u2192 `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) \u2192 `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings \u2192 General \u2192 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 \u2192 Network**. Supbuddy inserts a `pfctl` redirect rule into `/etc/pf.conf` (asks for sudo once) and reports whether the redirect is actually being enforced via a live 443 probe \u2014 not merely that the rule is on disk. If port forwarding is on but 443 won't connect, see [Port forwarding is on but 443 won't connect](#port-forwarding-is-on-but-443-wont-connect).\n\n> If the one-time sudo prompt is cancelled or fails, Supbuddy no longer aborts the start: Caddy still comes up and HTTPS keeps working on the high port (8443), and the proxy shows a degraded **error** state with a **Retry** so you can re-run the privileged setup. The CA is still generated in this state.\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 \u2192 port pair (e.g. `api.acme.test \u2192 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, \u2026) so every project's dev servers keep their canonical ports \u2014 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- **\u22EF 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 **\u2192 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 **\u22EF menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain \u2192 :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_\u2026)` 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 **\u22EF 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 \u2192 **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 \u2014 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, \u2026, persisted per project). Its app dev servers bind that IP instead of `127.0.0.1`, so canonical ports never collide across projects \u2014 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] \u2192 https://web.<project>.test`) \u2014 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 \u2014 humans or agents \u2014 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* \u2014 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 \u2192 General \u2192 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\nResolver files exist only for domains the proxy actually serves \u2014 the same set that gets a Caddy site block: enabled mappings that are either standalone or under an **enabled** project. Disable or delete a project and its resolver file is removed with its routes (one sudo prompt, and only when something really changed), so its domains go back to failing as \"server not found\" instead of resolving into a TLS handshake error from a proxy that has nothing to serve. Enabling it again writes the file back; so does restarting the proxy.\n\n### Per-project TLD\n\nBy default every project's domain uses the global TLD (Settings \u2192 Default TLD, e.g. `.test`). A single project can opt into its **own** TLD \u2014 set the suffix in the project dialog, pass `tld` to the `register_project` / `update_project` MCP tools, or use the CLI: `supbuddy project add <path> --tld=portal` when registering, or `supbuddy project set <project> --tld=portal` on an existing one (`--tld=` with an empty value clears the override). That project's base domain and all its subdomains then live on the override TLD (e.g. `cueplusplus.portal`, `web.cueplusplus.portal`) while every other project stays on the global default. The override is durable across restarts and is unaffected when you change the global TLD. Prefer `.test` or a vanity label like `.portal`; avoid `.local` (it collides with macOS mDNS/Bonjour).\n\n### LAN sharing\n\nWhen LAN sharing is enabled (Settings \u2192 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 \u2192 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\u2026** 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). The edit is strictly additive: existing array entries are kept verbatim, including spreads (`...devHosts`), identifiers and comments, and only the missing origins are appended.\n\nIf `allowedOrigins` (or `serverActions`, or `experimental`) is set to something other than a plain array/object literal \u2014 an identifier, a function call, a ternary, `[...] as string[]` \u2014 Supbuddy **refuses to patch** rather than guess, and the dialog says so along with the exact origins to add. This is deliberate: a wrong rewrite would produce a duplicate key (TypeScript `TS1117`) that breaks your build long after the fact, so the fallback is the copyable snippet. Use it and edit by hand.\n\nAfter 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`. Over MCP the same audit is exposed as `preview_next_origins` / `apply_next_origins`; both return `ok: false` with an explanation in the refusal case, and `apply_next_origins` never writes a file it cannot verify.\n\n### Next.js cross-origin dev requests (allowedDevOrigins)\n\nSupbuddy proxies your dev server but **passes the browser's real `Origin` header through** (it no longer rewrites `Origin` to the upstream address). That's required so Server Actions and other origin checks see the actual page origin \u2014 but it means **Next.js 15.3+ and 16** dev servers, which validate cross-origin dev requests against `allowedDevOrigins` (defaulting to `localhost`), now treat a request arriving on a Supbuddy domain (or a Thin project's `127.0.0.N` loopback IP) as cross-origin and can reject it. Add your Supbuddy domain to `allowedDevOrigins` in `next.config`:\n\n```js\n// next.config.js\nmodule.exports = {\n allowedDevOrigins: ['web.myproject.test'],\n}\n```\n\nRestart the dev server afterward; Next.js does not hot-reload `next.config`. This is separate from `experimental.serverActions.allowedOrigins` (the Server Actions CSRF list above) \u2014 15.3+/16 may need both.\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 (\"\u2026\") 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\u2026** 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). The edit is strictly additive \u2014 existing entries, spreads and comments are kept verbatim and only missing hosts are appended \u2014 and, exactly as with the Next.js audit, Supbuddy **refuses to patch** when `allowedHosts` or `server` is set to anything other than a plain array/object literal, pointing you at the snippet instead of risking a duplicate-key build break. 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 \u2192 MCP \u2192 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). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (**Settings \u2192 MCP \u2192 Clients**: install, edit scopes, set-primary, rotate token, revoke) drives each client from the app.\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- Supbuddy Cloud (opt-in, per-project): `cloud_sign_in`, `push_to_cloud`, `get_cloud_status`, `cloud_teardown` \u2014 push a project (with its Supabase schema + data) to a hosted cloud stack and control it. The `cloud` link (`{ projectId, stackId, pushedAt, url }`) also appears on `get_project` / `list_projects`, so any client sees which projects are in the cloud.\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- System doctor: `doctor` (scope `read`) runs the read-only health & drift scan and returns a report of findings (each with a `checkId`, severity, evidence, and whether it's `fixable`) \u2014 it mutates nothing. `doctor_fix` ( `{ check_ids: [...] }` ) applies the opt-in repairs for those checks; it's **system-scoped and confirm-gated** (a modal, exactly like `uninstall_ca`), so a read-scoped client can't trigger a fix and an agent can't silently run a destructive repair. Backs `supbuddy doctor` / `doctor --fix` (see *System doctor*).\n- System reset: `system_wipe` ( `{ tier: \"soft\" | \"deep\" }` , scope `system`) runs the tiered reset described under *System reset*. It is gated **twice**: it always returns a plan first \u2014 even for `auto_apply` clients \u2014 whose `side_effects` are the literal manifest the wipe will execute, and the subsequent `apply` still blocks on a user confirmation modal. `tier: \"full\"` is **rejected**: it deletes the credentials the caller is authenticating with, and its final steps (uninstalling the service, removing the app-data directory) can't run inside the daemon \u2014 run `supbuddy reset --tier=full` in a terminal instead.\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 \u2192 MCP \u2192 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 \u2192 :3400`), derives the host service subdomains (`api.`, `studio.`, \u2026), 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 \u2014 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 \u2014 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> \u2026`. 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\": \"\u2026\",\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\": \"\u2026\", \"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** \u2192 one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** \u2192 `~/.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## Supbuddy Cloud\n\nPush a project \u2014 its Supabase schema **and data** \u2014 to a hosted cloud dev-stack (its own full self-hosted Supabase \u2014 Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway \u2014 as an isolated graph of machines on a per-tenant private network) and control it from the app, the CLI, or MCP. **Opt-in and per-project:** nothing cloud-related appears in a project until you've signed in.\n\n- **Get started** \u2014 the top bar shows a **Get started with Supbuddy Cloud** strip; sign in (email/password) there. Once signed in it becomes **Open cloud** (opens [cloud.supbuddy.app](https://cloud.supbuddy.app) in your browser). Sign-in state + the Claude connection also live under **Settings \u2192 Cloud**.\n- **Push a project** \u2014 after signing in, each project's \u22EF menu gains **Push to cloud\u2026**. The push ships the project's stack descriptor + a `pg_dump` of its Supabase data (fail-closed: uploaded to a private bucket via a single-use key, sha-verified, restored *inside* the stack's private network, then deleted). Your **local project stays intact** \u2014 a **\u2601** badge appears on its row; click it (or \u22EF \u2192 **Open in cloud**) to open the stack in the web app.\n- **CLI / MCP** \u2014 the same flow headless: `supbuddy cloud login|push|status|teardown` (password via arg or `SUPBUDDY_CLOUD_PASSWORD`), or the `push_to_cloud` / `get_cloud_status` / `cloud_teardown` / `cloud_sign_in` MCP tools. `project ls` marks pushed projects with \u2601, and `get_project` / `list_projects` carry the `cloud` link. `cloud_teardown` (and the \u22EF teardown) destroy the remote stack and unlink it locally \u2014 routed through the same plan/apply gate as other destructive tools.\n- **Service breadth** \u2014 a self-hosted push provisions the **full** Supabase stack by default. Pass `push_to_cloud`'s `supabase_services: \"minimal\"` (MCP) to opt down to a lean db/auth/REST stack instead.\n- **Idle auto-stop** \u2014 a running cloud stack that reports no activity for ~30 minutes is automatically **stopped** to save cost (its data + config persist; start it again from the web app). A background reaper also reconciles any stack whose machines went missing.\n- **Web console** \u2014 [cloud.supbuddy.app](https://cloud.supbuddy.app) lists your org's stacks; open one for its per-service health, live status, and **start / stop / restart / tear down** controls, plus a **Recent activity** feed of control-plane events. **Push to cloud** in the console provisions a stack from a GitHub `owner/repo` (self-hosted or bring-your-own Supabase; full or minimal service set) \u2014 the code-only path; pushing a local project *with its data* still goes through the desktop app / CLI.\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>` \u2014 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`, `doctor [--fix]` (health & drift scan \u2014 see *System doctor*), `reset [--tier=soft\\|deep\\|full]` (tiered system reset \u2014 see *System reset*), `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| Cloud | `cloud login <email> [<pw>]` (or `SUPBUDDY_CLOUD_PASSWORD`), `cloud push <proj> [--repo=owner/repo] [--force]`, `cloud status [<proj>]`, `cloud teardown <proj>` \u2014 push a project (with its Supabase data) to a hosted cloud stack; `project ls` marks pushed projects with \u2601 |\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>\u2026` |\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 \u2192 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### System doctor\n\n```bash\nsupbuddy doctor # read-only scan; prints findings by severity\nsupbuddy doctor --fix # scan, show the repair manifest, confirm (y/N), then apply\nsupbuddy doctor --fix --only=ca-not-trusted # restrict repairs to specific check ids (comma-separated)\nsupbuddy doctor --fix --yes # skip the interactive confirm (scripting / CI)\n```\n\n`supbuddy doctor` runs a **read-only** health and drift scan and prints its findings grouped by severity \u2014 **critical**, **warning**, **info** \u2014 each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing and exits non-zero when any finding is **critical**, so you can gate a script or CI on it.\n\n`--fix` re-scans, prints a **manifest** \u2014 one line per fixable finding, taken from the scan you just saw \u2014 and, unless you pass `--yes`, asks `Apply these fixes? [y/N]` (default **No**) before touching anything. (The desktop app's doctor panel shows the finer-grained repair *actions* themselves; the CLI lists the findings those actions belong to.) `--only=<comma,ids>` restricts the repair to specific check ids; `--yes` skips the prompt for non-interactive use. This is the **confirm-before-harm** contract: the scan is read-only, and every repair is opt-in and gated. Fixes that need elevated access prompt for your password when they run.\n\nA repair that ends up doing nothing is reported as such, never as success: if a requested check's finding is already gone, is advisory, can't be re-checked, or names an unknown id, it's listed under **NOT APPLIED** and the command exits non-zero.\n\nThe doctor ships **17 checks**. Rows marked **Advisory** have **no auto-fix at all**: `--fix` will never touch them, and the finding's detail tells you what to do by hand. Checks marked *macOS* return nothing on other platforms.\n\n| Check id | Severity | What it flags | Auto-fix |\n| --- | --- | --- | --- |\n| `state-corrupt` | critical | `state.json` can't be parsed (or isn't an object), so the daemon boots with **empty** state \u2014 no projects, mappings, settings or MCP clients | Copies the file aside as `state.json.corrupt-<timestamp>` so you can hand-recover it. Nothing is deleted or rewritten |\n| `caddy-stuck` | critical | Caddy is alive but its admin API is wedged, so config reloads can't land | Restarts Caddy (stop \u2192 start) |\n| `ca-not-trusted` | warning | The local CA exists but isn't in the System keychain (the padlock stays broken) | Installs it into the System keychain (`security add-trusted-cert`; asks for your password) |\n| `pf-not-enforcing` | warning | Port forwarding is configured but 443 isn't redirecting | **Advisory \u2014 no auto-fix.** Run `supbuddy proxy restart`: only that path re-runs the privileged pf setup, so the doctor won't claim a success it can't deliver |\n| `duplicate-caddy-ca` | warning | *macOS.* Stale same-name `Caddy Local Authority` roots with a different key \u2014 the cause of Firefox-family `SEC_ERROR_BAD_SIGNATURE` | Deletes the stale roots from the System keychain, keeping the current one (asks for your password) |\n| `orphan-caddy-container` | warning | A leftover pre-binary-era `supbuddy-caddy` Docker container | Removes the container, its `supbuddy-net` network and its data/config volumes (the `caddy:latest` image is kept) |\n| `orphan-lo0-aliases` | warning | *macOS.* `127.0.0.N` aliases on `lo0` owned by no Thin project \u2014 deleting a Thin project never tore its alias down | Removes only those aliases (asks for your password); `127.0.0.1` and any non-Supbuddy alias are left alone |\n| `orphan-dind` | warning | Docker-in-Docker containers from the retired Isolated (VM) mode belonging to no registered project \u2014 each one confirmed to actually be a DinD first | Force-removes those containers and their `<name>-docker` data volumes. **This is project data**: if you deleted a project and chose to keep its data, this is that data. The Caddy container and non-Supbuddy containers are never touched |\n| `orphan-supabase-volumes` | warning | Docker volumes of Supbuddy-managed (`sb-`-prefixed) Supabase stacks owned by no registered project | Removes those volumes. **This is database data.** Host-mode stacks, stacks you started yourself, and projects still in the MCP trash (restorable for 7 days) are never touched |\n| `orphan-launchagents` | warning | *macOS.* Legacy CA-trust LaunchAgents from older builds that re-export `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` at every login and break **public** TLS | Boots each agent out and removes it, leaving a `.supbuddy-backup` copy alongside. Root-owned agents under `/Library` may resist; the fix reports those as a failure instead of claiming success |\n| `orphan-electron-token-files` | warning | Leftover `~/.config/Supbuddy/mcp/<clientId>.bin` token files from the retired Electron app, for clients that no longer exist | Deletes those files (no elevation). They can't be decrypted any more anyway; clients that are merely revoked keep their record and are left alone |\n| `orphan-mcp-secrets` | warning | `secrets/mcp-<clientId>.secret` files whose token can no longer authenticate (client revoked, or no record at all) | Deletes those files (no elevation) \u2014 it can't log a working agent out. Secrets for current clients, and the non-MCP secrets stored alongside them (license, cloud session, Tailscale key), are left untouched |\n| `unmanaged-supabase` | info | A Supabase stack on the host daemon that maps to no registered project (e.g. a plain `supabase start`) | **Advisory \u2014 no auto-fix.** Supbuddy never tears down a stack you started yourself; run `supabase stop` in its project if you don't need it |\n| `stale-resolver-files` | info | *macOS.* Supbuddy-marked `/etc/resolver/<suffix>` files for suffixes no **enabled** project or mapping claims any more (deleted projects, a disabled one, an older per-project TLD) | Removes only those files (asks for your password); suffixes still in use are left alone. Reversible \u2014 enabling the project or restarting the proxy writes the file back |\n| `pf-conf-backups` | info | *macOS.* `/etc/pf.conf.backup.<timestamp>` copies piled up in `/etc` by older versions (which wrote a new one on every port-forwarding disable) | Removes the redundant copies, **keeping the newest one** and the stable `/etc/pf.conf.supbuddy-backup` (asks for your password) |\n| `stale-mcp-config-tokens` | info | An agent config (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, or a registered project's `.mcp.json` / `.cursor/mcp.json`) holds a `mcpServers.supbuddy` token Supbuddy no longer accepts \u2014 the 401 \"Token not recognized\" state | **Advisory \u2014 no auto-fix.** Supbuddy won't rewrite config files you own and edit. Delete the `mcpServers.supbuddy` entry from the file named in the finding, or run `supbuddy mcp add <agent>` to mint a fresh token. The finding names the file, never the token |\n| `stale-browser-nss-roots` | info | *macOS.* A Firefox / Zen / LibreWolf / Waterfox profile whose own NSS store (`cert9.db`) holds a `Caddy Local Authority` root Supbuddy can't reach | **Advisory \u2014 no auto-fix.** Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings \u2192 Privacy & Security \u2192 Certificates \u2192 View Certificates\u2026 \u2192 Authorities, delete every `Caddy Local Authority` entry, then re-import Supbuddy's CA |\n\nThe same scan and repairs are available over MCP as the `doctor` and `doctor_fix` tools (see *MCP tool surface*), and in the app under **Settings \u2192 General \u2192 System health \u2192 Scan** \u2014 the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see *Settings reference \u2192 General*). The panel has no reset button: a wipe stays a CLI operation.\n\n### System reset\n\n```bash\nsupbuddy reset # soft (the default): app state + caches\nsupbuddy reset --tier=deep # + services, Caddy containers, system integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\nsupbuddy reset --tier=deep --yes # skip the y/N confirm (scripting / CI)\nsupbuddy reset --tier=full --yes --i-understand # the ONLY scripted path for a full reset\n```\n\n`supbuddy reset` removes Supbuddy's footprint from your machine in **tiers**, and each tier is a superset of the one before it:\n\n| Tier | What it removes |\n| --- | --- |\n| `soft` (default) | App state \u2014 projects, mappings, settings, MCP clients, project-context sync and user-skill records \u2014 plus the Docker image cache (`<app-data>/image-cache`, images are re-pulled on demand) and the buffered request log. It touches **no** Docker container or volume, **nothing** under `/etc`, and **no** file in your repos, so it never asks for your password |\n| `deep` | \u2026plus: stops every service; removes the leftover Caddy container/network/volumes, the `/etc/hosts` entries, the `/etc/resolver` files, the pf `:80`/`:443` redirect, the `127.0.0.N` loopback aliases, the bundled-runtime CA trust and the `Caddy Local Authority` roots in your keychain, and the token files of already-revoked MCP clients. **Your data is preserved**: no Supabase volume, no DinD container, no repo file and no *live* MCP token is touched \u2014 `deep` unwinds what Supbuddy installed on the machine, it is not a data wipe |\n| `full` | \u2026plus **your project data, backed up first**: every Supbuddy-**managed** (`sb-`-prefixed) Supabase stack's data volumes and every DinD container with its data volume, the `.supbuddy/` directories, managed blocks and `.env.supbuddy` files in your registered repos, and **every** credential (license, live MCP tokens, cloud session, Tailscale key) \u2014 then it uninstalls the start-on-login service and empties the app-data directory. A **host-mode** project's Supabase stack is only *stopped*: those containers and volumes are yours, and they are kept |\n\nMost steps enumerate what's actually on your machine first, so anything that isn't there drops out of the manifest instead of being advertised and skipped. `soft` needs no elevated access at all. `deep` batches the pf redirect, the resolver configuration and the loopback aliases into **one** password prompt; the legacy `/etc/hosts` block and the keychain CA removal ask separately, so expect up to three. `full` may prompt more than once as it tears projects down.\n\n**Reset is a CLI operation, on purpose \u2014 there is no reset button in the app.** The gates that make a wipe safe don't survive the trip into a GUI: a typed `RESET`, a refusal on non-interactive input, and a daemon confirmation the app itself would be answering. On top of that, `--tier=full` refuses outright while the desktop app is running (its watchdog respawns the daemon ~20s after it stops), so a button for it would be a trap. The app's **Settings \u2192 General \u2192 System health** panel points here instead.\n\n**Backup before harm.** Anything you can't regenerate \u2014 `state.json`, every managed Supabase database that is running (`pg_dump`, custom format, with a `.sha256` alongside), every managed data volume (`tar.gz`, verified with `gzip -t`) \u2014 is written to `<app-data>/backups/reset-<timestamp>/` **before** a single destructive step runs, and if any backup fails the whole reset **aborts before destroying anything**. The directory is printed prominently before you confirm, and again when the reset finishes; `manifest.json` inside it records exactly what was planned and what ran. On top of that coarse guarantee, each volume is gated individually: **no archive, no removal** \u2014 a volume with no non-empty `.tar.gz` next to it is left alone and the run records why.\n\n**A backup that can't be written stops the reset \u2014 safely.** Archiving a volume is given ten minutes; a genuinely large one (tens of GB of Postgres data plus a DinD image cache) can exceed that, and when it does the reset **aborts with nothing destroyed**. Stop the stack and prune what you don't need (`docker system prune`, drop old branches/schemas), or archive that volume yourself, then run the reset again. The same applies to any other backup failure: a full disk, an unreadable volume, a Docker daemon that stops answering.\n\n**The backups survive a full reset.** They live inside the app-data directory, so the last step of `--tier=full` empties that directory *content-wise and skips `backups/`* rather than deleting it wholesale. Move that directory somewhere safe afterwards \u2014 it's the only copy.\n\n**Confirmation.** Every tier prints the **manifest** first \u2014 the literal list of actions that will run, derived from the same actions the engine executes. `soft` and `deep` then ask `Apply this \"<tier>\" reset? [y/N]` (default **No**); `--yes` skips that prompt. `--tier=full` requires you to **type the word `RESET`** \u2014 `--yes` alone does **not** bypass it. The one scripted path for a full reset is `--yes --i-understand`, both flags together. Every prompt refuses on a non-interactive (piped) stdin rather than proceeding.\n\n**The daemon confirms too.** `soft` and `deep` run inside the daemon, which asks for its own approval before it starts \u2014 the same gate as `doctor --fix` and `ca uninstall`. With the Supbuddy app open you get a native **Allow / Deny** dialog. A daemon with neither a dialog nor a terminal \u2014 the start-on-login service, or an app-spawned daemon while the app is closed \u2014 has nobody to ask and **denies**; run a foreground `supbuddy daemon` in one terminal and the reset from a second, and it will prompt there. Don't reach for `supbuddy daemon --yes` to get past it: that auto-approves *every* confirmation for that daemon's whole lifetime.\n\n**Quit the app before a full reset.** The desktop app supervises the daemon and restarts it about 20 seconds after it stops, which would put a live daemon back into the directory the last step clears. `--tier=full` refuses up front while the app is running \u2014 before it asks you to type `RESET`, and before it changes anything. Quit the app (menu bar icon \u2192 Quit) and run it again; the quit dialog's default **Leave running** is fine, since the reset stops the daemon itself. The check looks for the *app* process only, so nothing else has to change. `--tier=full` also runs with no daemon at all, so if you quit with **Stop service** you can go straight ahead.\n\n**The order of a full reset**, once you've confirmed: the start-on-login service is uninstalled, the daemon is stopped and waited for (the reset refuses to run against a live daemon, which would rewrite `state.json` underneath it), the backup and teardown steps above run, and only then is the app-data directory emptied \u2014 keeping `backups/`. If the reset aborted, or if a daemon came back while it was running, the app-data directory is left in place and the CLI tells you so rather than clearing it under a live process.\n\n`soft` and `deep` are also available over MCP as the plan-gated `system_wipe` tool (see *MCP tool surface*). `--tier=full` is **CLI-only**: it deletes the credentials any agent would be calling with, and a daemon cannot uninstall the service it runs under or delete the directory it runs from.\n\n**What a full reset does not remove.** It only ever touches paths of **registered** projects \u2014 there is no disk scan for stray `.supbuddy` directories \u2014 and it won't delete or rewrite files whose ownership is ambiguous. So after `--tier=full` these are still on disk, and you can remove them by hand:\n\n- Per-editor rule files Supbuddy wrote in your repos: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.idea/supbuddy.md`. Shared files (`CLAUDE.md`, `AGENTS.md`, `.gitignore`, \u2026) keep their content and only lose Supbuddy's sentinel-delimited block.\n- Values `apply_env` merged into your **own** `.env*` files. The fully-owned `.env.supbuddy` files *are* deleted.\n- The bare `.env.supbuddy` line in `.gitignore` \u2014 it sits outside the managed block.\n- `vite.config.*` `allowedHosts` and `next.config.*` dev-origin patches.\n- `supabase/config.toml` port / `project_id` patches, when restoring the original file failed during the Thin teardown.\n- MCP client config entries written by `mcp add` / `install_mcp_config` (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, a project `.mcp.json` / `.cursor/mcp.json`). The token they hold is dead the moment the secrets are deleted; `supbuddy doctor`'s `stale-mcp-config-tokens` check will name each file.\n- The `caddy:latest` Docker image (shared and re-pullable) and anything a host-mode project owns.\n- The Supbuddy app itself \u2014 drag `Supbuddy.app` to the Trash \u2014 and the backups directory, which is the whole point of keeping it.\n\n## Settings reference\n\nOpen Settings via the gear icon top-right or by clicking the tray icon \u2192 Open Dashboard \u2192 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, \u2026) 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. It probes the **real access path** (port 443 when port forwarding is on, otherwise the high port), matching what real clients hit, so it doesn't false-negative against a port nothing is forwarding.\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- **System health** (**Scan**): opens the **System Doctor** panel \u2014 the same read-only, 17-check health & drift scan as `supbuddy doctor` (see *System doctor*), in the app. Opening the panel only scans; it changes nothing.\n - Findings are grouped **critical \u2192 warning \u2192 info**, each with its title, one-line detail, concrete evidence (paths, container names, fingerprints), check id and category. **Rescan** re-runs the scan; the header shows the counts. A scan that times out says so and points at `supbuddy doctor` \u2014 the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels.\n - **Fix\u2026** on a fixable finding \u2014 or **Fix all (n)** in the header \u2014 never repairs anything by itself. It opens the **manifest**: the literal list of actions that would run, each marked *destructive* or *safe*, built from the same actions the engine executes. **Apply** stays disabled until that manifest has loaded and contains at least one action, so an empty or failed plan can't be rubber-stamped. Same confirm-before-harm contract as `doctor --fix`.\n - Repairs that need elevated access ask for your password when they run. One that outlives the app's 15-second reply window (a password prompt sitting open) is reported as *may still be running \u2014 rescan in a moment*, not as a failure.\n - Findings with no auto-fix show **advisory** instead of a Fix button; the detail says what to do by hand. Checks that couldn't run at all are listed at the bottom as *Checks that could not run*, rather than being silently dropped.\n - **There is no reset button here, on purpose** \u2014 the footer points at `supbuddy reset` instead. See *System reset*.\n\n### Network\n\n- **HTTP port**: default 8080.\n- **HTTPS port**: default 8443.\n- **DNS port**: default 5353.\n- **Port forwarding**: when on, inserts a `pfctl` rule mapping 80\u2192HTTP port and 443\u2192HTTPS port into `/etc/pf.conf` (correct translation-section placement; self-heals a file corrupted by older versions). Asks for sudo once. Status reflects a live 443 enforcement probe, not just file presence.\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**: **Install** adds Caddy's root cert to your System keychain (removing any stale same-name roots first); **Uninstall** removes every `Caddy Local Authority` root it added. macOS asks for your password each time.\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 **\u22EF** 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 \u2194 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: \u2026**: 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### Run a health & drift scan first (`supbuddy doctor`)\n\nWhen something's off, `supbuddy doctor` is the quickest triage. It runs a **read-only** scan of 17 checks and prints findings by severity, and many of the issues below have a matching check \u2014 an unreadable `state.json`, an untrusted CA, a wedged Caddy, port 443 not redirecting, stale duplicate CA roots, legacy CA-trust LaunchAgents poisoning public TLS, an agent config still holding a revoked MCP token, a Firefox profile pinning an old Caddy root, and leftovers from deleted projects (Docker containers/volumes, `127.0.0.N` loopback aliases, `/etc/resolver` files, MCP token files). Add `--fix` to apply the opt-in repairs after a confirmation prompt \u2014 some checks are advisory and have no auto-fix. See [System doctor](#system-doctor) for the full check list and flags.\n\n### Browser shows \"Not secure\" or certificate warning\n\nThe Caddy CA is not trusted. Open **Settings \u2192 Network \u2192 Install Certificate**. macOS will prompt for your password. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* \u2192 System keychain \u2192 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 \u2192 General \u2192 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 \u2192 **Clean up leftovers\u2026** 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 `\u2026/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 \u2192 MCP \u2192 the client's \u22EF menu \u2192 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\u2026** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\nOn **Next.js 15.3+/16**, a proxied dev request can also be blocked (e.g. a \"Cross origin request detected\" warning) because Supbuddy now passes the real browser `Origin` through rather than rewriting it, and Next validates it against `allowedDevOrigins` (which defaults to `localhost`). Add your Supbuddy domain to `allowedDevOrigins` in `next.config` \u2014 see [Next.js cross-origin dev requests](#nextjs-cross-origin-dev-requests-alloweddevorigins). This is a separate key from the Server Actions list; 15.3+/16 may need both.\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\u2026** 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### Supabase Realtime: channel reaches `SUBSCRIBED` but no `postgres_changes` events arrive\n\nIf a channel subscribes fine (and writes succeed) but change events never fire, this is almost always **realtime warmup timing right after the stack starts** \u2014 not the Supbuddy proxy. Local Realtime can accept a channel join and report `SUBSCRIBED` before its logical-replication binding for the tenant is ready, so `INSERT`/`UPDATE`s in that brief window are silently missed. Give the stack a few seconds after the Supabase tab goes green, then re-subscribe (or reconnect the channel). This is **unrelated to the `.local` domain**: Kong routes `/realtime/v1/*` by path and rewrites the upstream `Host` to its internal realtime tenant, so reaching realtime through `https://api.<project>.local` behaves identically to the raw `localhost:54321` port \u2014 forwarding the `.local` host upstream does not change tenant resolution. The new `sb_publishable_*` / `sb_secret_*` API keys also work for local realtime (Kong maps them to the legacy JWT), so you don't need to switch key formats.\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\u21928443 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\u2192on 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 forwarding is on but 443 won't connect\n\nSupbuddy reports port forwarding as **active** only when a live probe confirms 443 actually reaches Caddy \u2014 the rule being on disk isn't enough. If the rule is present but not being enforced (typically right after a reboot, or when an older Supbuddy version left `/etc/pf.conf` in a broken state), the status carries a `pf_not_enforcing` diagnostic instead of a false \"enabled\", and the banner tells you to **restart the proxy** to re-apply the redirect.\n\nOlder versions appended their `rdr-anchor` to the **end** of `/etc/pf.conf`, after Apple's filter anchor \u2014 which pf rejects, because translation rules must come before filtering rules. That silently invalidated the whole ruleset, so every later `pfctl -f` failed and 443 was dead. Current builds insert the anchor in the correct translation section and **self-heal** a file corrupted by the old version on the next proxy start. Supbuddy keeps a single stable backup at `/etc/pf.conf.supbuddy-backup` (older builds accumulated unbounded timestamped backups). If a restart doesn't fix it, inspect `/etc/pf.conf` and confirm the `rdr-anchor \"virtual.localhost\"` line sits before `anchor \"com.apple/*\"`.\n\n### Proxy came up but shows a degraded \"error\" state\n\nIf the one-time sudo prompt for port forwarding / DNS is cancelled or fails, Supbuddy no longer aborts the whole start. Caddy still starts and HTTPS keeps working on the high port (8443), and the CA is still generated; the proxy just shows an actionable **error** (degraded) state with a **Retry**. Click **Retry** and approve the sudo prompt to restore real-port (80/443) access and DNS. Until then, reach your apps on `https://<domain>:8443`.\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 \u2192 Network** / **Settings \u2192 MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nUse `supbuddy reset` (see *System reset*) \u2014 it backs up anything you can't regenerate first, and it removes the things a plain `rm -rf` leaves behind (the pf redirect, the resolver files, the loopback aliases, the trusted CA):\n\n```bash\nsupbuddy reset --tier=soft # just the app state and caches\nsupbuddy reset --tier=deep # + services, Caddy leftovers, /etc integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\n```\n\nThe manual equivalent, if the CLI isn't available \u2014 quit Supbuddy first, and note that this deletes `secrets/` and any backups under it with no copy anywhere:\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 \u2192 General \u2192 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 \u2192 MCP \u2192 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 (the full reset refuses to run while it's open, because its watchdog restarts the daemon).\n2. Run `supbuddy reset --tier=full` and type `RESET` when it asks. This backs up your project data, then removes the containers, volumes, `/etc` integrations, CA trust, repo artifacts, credentials, the start-on-login service and the app-data directory \u2014 keeping `<app-data>/backups/reset-<timestamp>/`. See *System reset*, including the short list of things it deliberately leaves behind.\n3. Drag **Supbuddy.app** from `/Applications` to the Trash, and move the backups directory somewhere safe (or delete it).\n4. If you'd rather not use the CLI: see \"Wipe everything and start over\" above for the manual equivalent, plus `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";
39395
+ 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\nCaddy mints its local CA the first time it actually serves a site, so the cert only exists once you have **at least one enabled mapping and the proxy running** \u2014 an empty proxy never generates it. With that in place, open the app and click **Install** (the first-launch prompt, or **Settings \u2192 Network** later). Supbuddy adds the CA (Caddy's internal PKI at `~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt`) to your **System keychain** via `sudo security add-trusted-cert`; macOS asks for your password once. Caddy does **not** self-install trust (the generated Caddyfile sets `skip_install_trust`), so this button is what makes the padlock green \u2014 fully quit and reopen your browser afterward to pick it up. Every Supbuddy domain then gets HTTPS with no per-domain prompts or warnings. (On Windows the install is manual: Supbuddy shows the PowerShell `Import-Certificate \u2026 -CertStoreLocation Cert:\\LocalMachine\\Root` command to run as Administrator.)\n\nCaddy names its root by year, so each yearly rotation (or a data wipe) leaves a same-name root behind with a different key. On every Install, Supbuddy first removes any stale `Caddy Local Authority` roots whose fingerprint doesn't match the current one, then adds the current root \u2014 leftover mismatched roots otherwise make Firefox-family browsers fail with `SEC_ERROR_BAD_SIGNATURE`.\n\n**Firefox, Zen, and Brave keep their own certificate store** that Supbuddy can't reach (they don't consult the System keychain). After a CA change, either delete any stale `Caddy Local Authority` entries from the browser's own certificate manager and re-import the new root, or \u2014 on Firefox/Zen \u2014 set `security.enterprise_roots.enabled` to `true` in `about:config` so the browser reads the System keychain.\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 \u2192 General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings \u2192 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 \u2192 `api.<project>.test`\n- Supabase Studio \u2192 `studio.<project>.test`\n- Supabase Inbucket / Mailpit \u2192 `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) \u2192 `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings \u2192 General \u2192 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 \u2192 Network**. Supbuddy inserts a `pfctl` redirect rule into `/etc/pf.conf` (asks for sudo once) and reports whether the redirect is actually being enforced via a live 443 probe \u2014 not merely that the rule is on disk. If port forwarding is on but 443 won't connect, see [Port forwarding is on but 443 won't connect](#port-forwarding-is-on-but-443-wont-connect).\n\n> If the one-time sudo prompt is cancelled or fails, Supbuddy no longer aborts the start: Caddy still comes up and HTTPS keeps working on the high port (8443), and the proxy shows a degraded **error** state with a **Retry** so you can re-run the privileged setup. The CA is still generated in this state.\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 \u2192 port pair (e.g. `api.acme.test \u2192 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, \u2026) so every project's dev servers keep their canonical ports \u2014 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- **\u22EF 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 **\u2192 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 **\u22EF menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain \u2192 :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_\u2026)` 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 **\u22EF 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 \u2192 **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 \u2014 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, \u2026, persisted per project). Its app dev servers bind that IP instead of `127.0.0.1`, so canonical ports never collide across projects \u2014 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] \u2192 https://web.<project>.test`) \u2014 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 \u2014 humans or agents \u2014 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* \u2014 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 \u2192 General \u2192 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\nResolver files exist only for domains the proxy actually serves \u2014 the same set that gets a Caddy site block: enabled mappings that are either standalone or under an **enabled** project. Disable or delete a project and its resolver file is removed with its routes (one sudo prompt, and only when something really changed), so its domains go back to failing as \"server not found\" instead of resolving into a TLS handshake error from a proxy that has nothing to serve. Enabling it again writes the file back; so does restarting the proxy.\n\n### Per-project TLD\n\nBy default every project's domain uses the global TLD (Settings \u2192 Default TLD, e.g. `.test`). A single project can opt into its **own** TLD \u2014 set the suffix in the project dialog, pass `tld` to the `register_project` / `update_project` MCP tools, or use the CLI: `supbuddy project add <path> --tld=portal` when registering, or `supbuddy project set <project> --tld=portal` on an existing one (`--tld=` with an empty value clears the override). That project's base domain and all its subdomains then live on the override TLD (e.g. `cueplusplus.portal`, `web.cueplusplus.portal`) while every other project stays on the global default. The override is durable across restarts and is unaffected when you change the global TLD. Prefer `.test` or a vanity label like `.portal`; avoid `.local` (it collides with macOS mDNS/Bonjour).\n\n### LAN sharing\n\nWhen LAN sharing is enabled (Settings \u2192 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 \u2192 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\u2026** 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). The edit is strictly additive: existing array entries are kept verbatim, including spreads (`...devHosts`), identifiers and comments, and only the missing origins are appended.\n\nIf `allowedOrigins` (or `serverActions`, or `experimental`) is set to something other than a plain array/object literal \u2014 an identifier, a function call, a ternary, `[...] as string[]` \u2014 Supbuddy **refuses to patch** rather than guess, and the dialog says so along with the exact origins to add. This is deliberate: a wrong rewrite would produce a duplicate key (TypeScript `TS1117`) that breaks your build long after the fact, so the fallback is the copyable snippet. Use it and edit by hand.\n\nAfter 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`. Over MCP the same audit is exposed as `preview_next_origins` / `apply_next_origins`; both return `ok: false` with an explanation in the refusal case, and `apply_next_origins` never writes a file it cannot verify.\n\n### Next.js cross-origin dev requests (allowedDevOrigins)\n\nSupbuddy proxies your dev server but **passes the browser's real `Origin` header through** (it no longer rewrites `Origin` to the upstream address). That's required so Server Actions and other origin checks see the actual page origin \u2014 but it means **Next.js 15.3+ and 16** dev servers, which validate cross-origin dev requests against `allowedDevOrigins` (defaulting to `localhost`), now treat a request arriving on a Supbuddy domain (or a Thin project's `127.0.0.N` loopback IP) as cross-origin and can reject it. Add your Supbuddy domain to `allowedDevOrigins` in `next.config`:\n\n```js\n// next.config.js\nmodule.exports = {\n allowedDevOrigins: ['web.myproject.test'],\n}\n```\n\nRestart the dev server afterward; Next.js does not hot-reload `next.config`. This is separate from `experimental.serverActions.allowedOrigins` (the Server Actions CSRF list above) \u2014 15.3+/16 may need both.\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 (\"\u2026\") 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\u2026** 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). The edit is strictly additive \u2014 existing entries, spreads and comments are kept verbatim and only missing hosts are appended \u2014 and, exactly as with the Next.js audit, Supbuddy **refuses to patch** when `allowedHosts` or `server` is set to anything other than a plain array/object literal, pointing you at the snippet instead of risking a duplicate-key build break. 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 \u2192 MCP \u2192 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). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (**Settings \u2192 MCP \u2192 Clients**: install, edit scopes, set-primary, rotate token, revoke) drives each client from the app.\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- Supbuddy Cloud (opt-in, per-project): `cloud_sign_in`, `push_to_cloud`, `get_cloud_status`, `cloud_teardown` \u2014 push a project (with its Supabase schema + data) to a hosted cloud stack and control it. The `cloud` link (`{ projectId, stackId, pushedAt, url }`) also appears on `get_project` / `list_projects`, so any client sees which projects are in the cloud.\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- System doctor: `doctor` (scope `read`) runs the read-only health & drift scan and returns a report of findings (each with a `checkId`, severity, evidence, and whether it's `fixable`) \u2014 it mutates nothing. `doctor_fix` ( `{ check_ids: [...] }` ) applies the opt-in repairs for those checks; it's **system-scoped and confirm-gated** (a modal, exactly like `uninstall_ca`), so a read-scoped client can't trigger a fix and an agent can't silently run a destructive repair. Backs `supbuddy doctor` / `doctor --fix` (see *System doctor*).\n- System reset: `system_wipe` ( `{ tier: \"soft\" | \"deep\" }` , scope `system`) runs the tiered reset described under *System reset*. It is gated **twice**: it always returns a plan first \u2014 even for `auto_apply` clients \u2014 whose `side_effects` are the literal manifest the wipe will execute, and the subsequent `apply` still blocks on a user confirmation modal. `tier: \"full\"` is **rejected**: it deletes the credentials the caller is authenticating with, and its final steps (uninstalling the service, removing the app-data directory) can't run inside the daemon \u2014 run `supbuddy reset --tier=full` in a terminal instead.\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 \u2192 MCP \u2192 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 \u2192 :3400`), derives the host service subdomains (`api.`, `studio.`, \u2026), 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 \u2014 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 \u2014 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> \u2026`. 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\": \"\u2026\",\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\": \"\u2026\", \"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** \u2192 one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** \u2192 `~/.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## Supbuddy Cloud\n\nPush a project \u2014 its Supabase schema **and data** \u2014 to a hosted cloud dev-stack (its own full self-hosted Supabase \u2014 Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway \u2014 as an isolated graph of machines on a per-tenant private network) and control it from the app, the CLI, or MCP. **Opt-in and per-project:** nothing cloud-related appears in a project until you've signed in.\n\n- **Get started** \u2014 the top bar shows a **Get started with Supbuddy Cloud** strip; sign in (email/password) there. Once signed in it becomes **Open cloud** (opens [cloud.supbuddy.app](https://cloud.supbuddy.app) in your browser). Sign-in state + the Claude connection also live under **Settings \u2192 Cloud**.\n- **Push a project** \u2014 after signing in, each project's \u22EF menu gains **Push to cloud\u2026**. The push ships the project's stack descriptor + a `pg_dump` of its Supabase data (fail-closed: uploaded to a private bucket via a single-use key, sha-verified, restored *inside* the stack's private network, then deleted). Your **local project stays intact** \u2014 a **\u2601** badge appears on its row; click it (or \u22EF \u2192 **Open in cloud**) to open the stack in the web app.\n- **CLI / MCP** \u2014 the same flow headless: `supbuddy cloud login|push|status|teardown` (password via arg or `SUPBUDDY_CLOUD_PASSWORD`), or the `push_to_cloud` / `get_cloud_status` / `cloud_teardown` / `cloud_sign_in` MCP tools. `project ls` marks pushed projects with \u2601, and `get_project` / `list_projects` carry the `cloud` link. `cloud_teardown` (and the \u22EF teardown) destroy the remote stack and unlink it locally \u2014 routed through the same plan/apply gate as other destructive tools.\n- **Service breadth** \u2014 a self-hosted push provisions the **full** Supabase stack by default. Pass `push_to_cloud`'s `supabase_services: \"minimal\"` (MCP) to opt down to a lean db/auth/REST stack instead.\n- **Idle auto-stop** \u2014 a running cloud stack that reports no activity for ~30 minutes is automatically **stopped** to save cost (its data + config persist; start it again from the web app). A background reaper also reconciles any stack whose machines went missing.\n- **Web console** \u2014 [cloud.supbuddy.app](https://cloud.supbuddy.app) lists your org's stacks; open one for its per-service health, live status, and **start / stop / restart / tear down** controls, plus a **Recent activity** feed of control-plane events. **Push to cloud** in the console provisions a stack from a GitHub `owner/repo` (self-hosted or bring-your-own Supabase; full or minimal service set) \u2014 the code-only path; pushing a local project *with its data* still goes through the desktop app / CLI.\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>` \u2014 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`, `doctor [--fix]` (health & drift scan \u2014 see *System doctor*), `reset [--tier=soft\\|deep\\|full]` (tiered system reset \u2014 see *System reset*), `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| Cloud | `cloud login <email> [<pw>]` (or `SUPBUDDY_CLOUD_PASSWORD`), `cloud push <proj> [--repo=owner/repo] [--force]`, `cloud status [<proj>]`, `cloud teardown <proj>` \u2014 push a project (with its Supabase data) to a hosted cloud stack; `project ls` marks pushed projects with \u2601 |\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>\u2026` |\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 \u2192 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### System doctor\n\n```bash\nsupbuddy doctor # read-only scan; prints findings by severity\nsupbuddy doctor --fix # scan, show the repair manifest, confirm (y/N), then apply\nsupbuddy doctor --fix --only=ca-not-trusted # restrict repairs to specific check ids (comma-separated)\nsupbuddy doctor --fix --yes # skip the interactive confirm (scripting / CI)\n```\n\n`supbuddy doctor` runs a **read-only** health and drift scan and prints its findings grouped by severity \u2014 **critical**, **warning**, **info** \u2014 each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing and exits non-zero when any finding is **critical**, so you can gate a script or CI on it.\n\n`--fix` re-scans, prints a **manifest** \u2014 one line per fixable finding, taken from the scan you just saw \u2014 and, unless you pass `--yes`, asks `Apply these fixes? [y/N]` (default **No**) before touching anything. (The desktop app's doctor panel shows the finer-grained repair *actions* themselves; the CLI lists the findings those actions belong to.) `--only=<comma,ids>` restricts the repair to specific check ids; `--yes` skips the prompt for non-interactive use. This is the **confirm-before-harm** contract: the scan is read-only, and every repair is opt-in and gated. Fixes that need elevated access prompt for your password when they run.\n\nA repair that ends up doing nothing is reported as such, never as success: if a requested check's finding is already gone, is advisory, can't be re-checked, or names an unknown id, it's listed under **NOT APPLIED** and the command exits non-zero.\n\nThe doctor ships **18 checks**. Rows marked **Advisory** have **no auto-fix at all**: `--fix` will never touch them, and the finding's detail tells you what to do by hand. Checks marked *macOS* return nothing on other platforms.\n\n| Check id | Severity | What it flags | Auto-fix |\n| --- | --- | --- | --- |\n| `state-corrupt` | critical | `state.json` can't be parsed (or isn't an object), so the daemon boots with **empty** state \u2014 no projects, mappings, settings or MCP clients | Copies the file aside as `state.json.corrupt-<timestamp>` so you can hand-recover it. Nothing is deleted or rewritten |\n| `caddy-stuck` | critical | Caddy is alive but its admin API is wedged, so config reloads can't land | Restarts Caddy (stop \u2192 start) |\n| `caddy-ipv4-unreachable` | critical | Caddy's loaded config declares an HTTPS listener but `127.0.0.1:<port>` **refuses** connections \u2014 every IPv4 client is cut off (browsers, curl, and the pf 443\u21928443 redirect) while the process is up and its admin API answers | **Advisory \u2014 no auto-fix.** Run `supbuddy proxy restart` to rebind. Only a connection **refused** counts: a *timeout* on a pf redirect target is normal (the reply is reverse-NAT'd back to :443 and never matches your socket), so it is never reported as a fault |\n| `ca-not-trusted` | warning | The local CA exists but the **current** root isn't trusted in the System keychain (the padlock stays broken). Detection is by fingerprint, so a stale same-name root from an earlier CA no longer counts as installed | Installs it into the System keychain (`security add-trusted-cert`; asks for your password). Where trust **cannot be read at all** (Windows) this drops to **advisory, info, no auto-fix** \u2014 it reports what to import by hand rather than offering a repair that can't run |\n| `pf-not-enforcing` | warning | Port forwarding is configured but 443 isn't redirecting | **Advisory \u2014 no auto-fix.** Run `supbuddy proxy restart`: only that path re-runs the privileged pf setup, so the doctor won't claim a success it can't deliver |\n| `duplicate-caddy-ca` | warning | *macOS.* Stale same-name `Caddy Local Authority` roots with a different key \u2014 the cause of Firefox-family `SEC_ERROR_BAD_SIGNATURE` | Deletes the stale roots **and installs the current one** in a single elevated batch (asks for your password). Delete-only could leave a machine with no trusted Caddy root at all when the current one wasn't in the keychain yet |\n| `orphan-caddy-container` | warning | A leftover pre-binary-era `supbuddy-caddy` Docker container | Removes the container, its `supbuddy-net` network and its data/config volumes (the `caddy:latest` image is kept) |\n| `orphan-lo0-aliases` | warning | *macOS.* `127.0.0.N` aliases on `lo0` owned by no Thin project \u2014 deleting a Thin project never tore its alias down | Removes only those aliases (asks for your password); `127.0.0.1` and any non-Supbuddy alias are left alone |\n| `orphan-dind` | warning | Docker-in-Docker containers from the retired Isolated (VM) mode belonging to no registered project \u2014 each one confirmed to actually be a DinD first | Force-removes those containers and their `<name>-docker` data volumes. **This is project data**: if you deleted a project and chose to keep its data, this is that data. The Caddy container and non-Supbuddy containers are never touched |\n| `orphan-supabase-volumes` | warning | Docker volumes of Supbuddy-managed (`sb-`-prefixed) Supabase stacks owned by no registered project | Removes those volumes. **This is database data.** Host-mode stacks, stacks you started yourself, and projects still in the MCP trash (restorable for 7 days) are never touched |\n| `orphan-launchagents` | warning | *macOS.* Legacy CA-trust LaunchAgents from older builds that re-export `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` at every login and break **public** TLS | Boots each agent out and removes it, leaving a `.supbuddy-backup` copy alongside. Root-owned agents under `/Library` may resist; the fix reports those as a failure instead of claiming success |\n| `orphan-electron-token-files` | warning | Leftover `~/.config/Supbuddy/mcp/<clientId>.bin` token files from the retired Electron app, for clients that no longer exist | Deletes those files (no elevation). They can't be decrypted any more anyway; clients that are merely revoked keep their record and are left alone |\n| `orphan-mcp-secrets` | warning | `secrets/mcp-<clientId>.secret` files whose token can no longer authenticate (client revoked, or no record at all) | Deletes those files (no elevation) \u2014 it can't log a working agent out. Secrets for current clients, and the non-MCP secrets stored alongside them (license, cloud session, Tailscale key), are left untouched |\n| `unmanaged-supabase` | info | A Supabase stack on the host daemon that maps to no registered project (e.g. a plain `supabase start`) | **Advisory \u2014 no auto-fix.** Supbuddy never tears down a stack you started yourself; run `supabase stop` in its project if you don't need it |\n| `stale-resolver-files` | info | *macOS.* Supbuddy-marked `/etc/resolver/<suffix>` files for suffixes no **enabled** project or mapping claims any more (deleted projects, a disabled one, an older per-project TLD) | Removes only those files (asks for your password); suffixes still in use are left alone. Reversible \u2014 enabling the project or restarting the proxy writes the file back |\n| `pf-conf-backups` | info | *macOS.* `/etc/pf.conf.backup.<timestamp>` copies piled up in `/etc` by older versions (which wrote a new one on every port-forwarding disable) | Removes the redundant copies, **keeping the newest one** and the stable `/etc/pf.conf.supbuddy-backup` (asks for your password) |\n| `stale-mcp-config-tokens` | info | An agent config (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, or a registered project's `.mcp.json` / `.cursor/mcp.json`) holds a `mcpServers.supbuddy` token Supbuddy no longer accepts \u2014 the 401 \"Token not recognized\" state | **Advisory \u2014 no auto-fix.** Supbuddy won't rewrite config files you own and edit. Delete the `mcpServers.supbuddy` entry from the file named in the finding, or run `supbuddy mcp add <agent>` to mint a fresh token. The finding names the file, never the token |\n| `stale-browser-nss-roots` | info | *macOS.* A Firefox / Zen / LibreWolf / Waterfox profile whose own NSS store (`cert9.db`) holds a `Caddy Local Authority` root Supbuddy can't reach | **Advisory \u2014 no auto-fix.** Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings \u2192 Privacy & Security \u2192 Certificates \u2192 View Certificates\u2026 \u2192 Authorities, delete every `Caddy Local Authority` entry, then re-import Supbuddy's CA |\n\nThe same scan and repairs are available over MCP as the `doctor` and `doctor_fix` tools (see *MCP tool surface*), and in the app under **Settings \u2192 General \u2192 System health \u2192 Scan** \u2014 the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see *Settings reference \u2192 General*). The panel has no reset button: a wipe stays a CLI operation.\n\n### System reset\n\n```bash\nsupbuddy reset # soft (the default): app state + caches\nsupbuddy reset --tier=deep # + services, Caddy containers, system integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\nsupbuddy reset --tier=deep --yes # skip the y/N confirm (scripting / CI)\nsupbuddy reset --tier=full --yes --i-understand # the ONLY scripted path for a full reset\n```\n\n`supbuddy reset` removes Supbuddy's footprint from your machine in **tiers**, and each tier is a superset of the one before it:\n\n| Tier | What it removes |\n| --- | --- |\n| `soft` (default) | App state \u2014 projects, mappings, settings, MCP clients, project-context sync and user-skill records \u2014 plus the Docker image cache (`<app-data>/image-cache`, images are re-pulled on demand) and the buffered request log. It touches **no** Docker container or volume, **nothing** under `/etc`, and **no** file in your repos, so it never asks for your password |\n| `deep` | \u2026plus: stops every service; removes the leftover Caddy container/network/volumes, the `/etc/hosts` entries, the `/etc/resolver` files, the pf `:80`/`:443` redirect, the `127.0.0.N` loopback aliases, the bundled-runtime CA trust and the `Caddy Local Authority` roots in your keychain, and the token files of already-revoked MCP clients. **Your data is preserved**: no Supabase volume, no DinD container, no repo file and no *live* MCP token is touched \u2014 `deep` unwinds what Supbuddy installed on the machine, it is not a data wipe |\n| `full` | \u2026plus **your project data, backed up first**: every Supbuddy-**managed** (`sb-`-prefixed) Supabase stack's data volumes and every DinD container with its data volume, the `.supbuddy/` directories, managed blocks and `.env.supbuddy` files in your registered repos, and **every** credential (license, live MCP tokens, cloud session, Tailscale key) \u2014 then it uninstalls the start-on-login service and empties the app-data directory. A **host-mode** project's Supabase stack is only *stopped*: those containers and volumes are yours, and they are kept |\n\nMost steps enumerate what's actually on your machine first, so anything that isn't there drops out of the manifest instead of being advertised and skipped. `soft` needs no elevated access at all. `deep` batches the pf redirect, the resolver configuration and the loopback aliases into **one** password prompt; the legacy `/etc/hosts` block and the keychain CA removal ask separately, so expect up to three. `full` may prompt more than once as it tears projects down.\n\n**Reset is a CLI operation, on purpose \u2014 there is no reset button in the app.** The gates that make a wipe safe don't survive the trip into a GUI: a typed `RESET`, a refusal on non-interactive input, and a daemon confirmation the app itself would be answering. On top of that, `--tier=full` refuses outright while the desktop app is running (its watchdog respawns the daemon ~20s after it stops), so a button for it would be a trap. The app's **Settings \u2192 General \u2192 System health** panel points here instead.\n\n**Backup before harm.** Anything you can't regenerate \u2014 `state.json`, every managed Supabase database that is running (`pg_dump`, custom format, with a `.sha256` alongside), every managed data volume (`tar.gz`, verified with `gzip -t`) \u2014 is written to `<app-data>/backups/reset-<timestamp>/` **before** a single destructive step runs, and if any backup fails the whole reset **aborts before destroying anything**. The directory is printed prominently before you confirm, and again when the reset finishes; `manifest.json` inside it records exactly what was planned and what ran. On top of that coarse guarantee, each volume is gated individually: **no archive, no removal** \u2014 a volume with no non-empty `.tar.gz` next to it is left alone and the run records why.\n\n**A backup that can't be written stops the reset \u2014 safely.** Archiving a volume is given ten minutes; a genuinely large one (tens of GB of Postgres data plus a DinD image cache) can exceed that, and when it does the reset **aborts with nothing destroyed**. Stop the stack and prune what you don't need (`docker system prune`, drop old branches/schemas), or archive that volume yourself, then run the reset again. The same applies to any other backup failure: a full disk, an unreadable volume, a Docker daemon that stops answering.\n\n**The backups survive a full reset.** They live inside the app-data directory, so the last step of `--tier=full` empties that directory *content-wise and skips `backups/`* rather than deleting it wholesale. Move that directory somewhere safe afterwards \u2014 it's the only copy.\n\n**Confirmation.** Every tier prints the **manifest** first \u2014 the literal list of actions that will run, derived from the same actions the engine executes. `soft` and `deep` then ask `Apply this \"<tier>\" reset? [y/N]` (default **No**); `--yes` skips that prompt. `--tier=full` requires you to **type the word `RESET`** \u2014 `--yes` alone does **not** bypass it. The one scripted path for a full reset is `--yes --i-understand`, both flags together. Every prompt refuses on a non-interactive (piped) stdin rather than proceeding.\n\n**The daemon confirms too.** `soft` and `deep` run inside the daemon, which asks for its own approval before it starts \u2014 the same gate as `doctor --fix` and `ca uninstall`. With the Supbuddy app open you get a native **Allow / Deny** dialog. A daemon with neither a dialog nor a terminal \u2014 the start-on-login service, or an app-spawned daemon while the app is closed \u2014 has nobody to ask and **denies**; run a foreground `supbuddy daemon` in one terminal and the reset from a second, and it will prompt there. Don't reach for `supbuddy daemon --yes` to get past it: that auto-approves *every* confirmation for that daemon's whole lifetime.\n\n**Quit the app before a full reset.** The desktop app supervises the daemon and restarts it about 20 seconds after it stops, which would put a live daemon back into the directory the last step clears. `--tier=full` refuses up front while the app is running \u2014 before it asks you to type `RESET`, and before it changes anything. Quit the app (menu bar icon \u2192 Quit) and run it again; the quit dialog's default **Leave running** is fine, since the reset stops the daemon itself. The check looks for the *app* process only, so nothing else has to change. `--tier=full` also runs with no daemon at all, so if you quit with **Stop service** you can go straight ahead.\n\n**The order of a full reset**, once you've confirmed: the start-on-login service is uninstalled, the daemon is stopped and waited for (the reset refuses to run against a live daemon, which would rewrite `state.json` underneath it), the backup and teardown steps above run, and only then is the app-data directory emptied \u2014 keeping `backups/`. If the reset aborted, or if a daemon came back while it was running, the app-data directory is left in place and the CLI tells you so rather than clearing it under a live process.\n\n`soft` and `deep` are also available over MCP as the plan-gated `system_wipe` tool (see *MCP tool surface*). `--tier=full` is **CLI-only**: it deletes the credentials any agent would be calling with, and a daemon cannot uninstall the service it runs under or delete the directory it runs from.\n\n**What a full reset does not remove.** It only ever touches paths of **registered** projects \u2014 there is no disk scan for stray `.supbuddy` directories \u2014 and it won't delete or rewrite files whose ownership is ambiguous. So after `--tier=full` these are still on disk, and you can remove them by hand:\n\n- Per-editor rule files Supbuddy wrote in your repos: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.idea/supbuddy.md`. Shared files (`CLAUDE.md`, `AGENTS.md`, `.gitignore`, \u2026) keep their content and only lose Supbuddy's sentinel-delimited block.\n- Values `apply_env` merged into your **own** `.env*` files. The fully-owned `.env.supbuddy` files *are* deleted.\n- The bare `.env.supbuddy` line in `.gitignore` \u2014 it sits outside the managed block.\n- `vite.config.*` `allowedHosts` and `next.config.*` dev-origin patches.\n- `supabase/config.toml` port / `project_id` patches, when restoring the original file failed during the Thin teardown.\n- MCP client config entries written by `mcp add` / `install_mcp_config` (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, a project `.mcp.json` / `.cursor/mcp.json`). The token they hold is dead the moment the secrets are deleted; `supbuddy doctor`'s `stale-mcp-config-tokens` check will name each file.\n- The `caddy:latest` Docker image (shared and re-pullable) and anything a host-mode project owns.\n- The Supbuddy app itself \u2014 drag `Supbuddy.app` to the Trash \u2014 and the backups directory, which is the whole point of keeping it.\n\n## Settings reference\n\nOpen Settings via the gear icon top-right or by clicking the tray icon \u2192 Open Dashboard \u2192 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, \u2026) 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. It probes the **real access path** (port 443 when port forwarding is on, otherwise the high port), matching what real clients hit, so it doesn't false-negative against a port nothing is forwarding.\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- **System health** (**Scan**): opens the **System Doctor** panel \u2014 the same read-only, 17-check health & drift scan as `supbuddy doctor` (see *System doctor*), in the app. Opening the panel only scans; it changes nothing.\n - Findings are grouped **critical \u2192 warning \u2192 info**, each with its title, one-line detail, concrete evidence (paths, container names, fingerprints), check id and category. **Rescan** re-runs the scan; the header shows the counts. A scan that times out says so and points at `supbuddy doctor` \u2014 the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels.\n - **Fix\u2026** on a fixable finding \u2014 or **Fix all (n)** in the header \u2014 never repairs anything by itself. It opens the **manifest**: the literal list of actions that would run, each marked *destructive* or *safe*, built from the same actions the engine executes. **Apply** stays disabled until that manifest has loaded and contains at least one action, so an empty or failed plan can't be rubber-stamped. Same confirm-before-harm contract as `doctor --fix`.\n - Repairs that need elevated access ask for your password when they run. One that outlives the app's 15-second reply window (a password prompt sitting open) is reported as *may still be running \u2014 rescan in a moment*, not as a failure.\n - Findings with no auto-fix show **advisory** instead of a Fix button; the detail says what to do by hand. Checks that couldn't run at all are listed at the bottom as *Checks that could not run*, rather than being silently dropped.\n - **There is no reset button here, on purpose** \u2014 the footer points at `supbuddy reset` instead. See *System reset*.\n\n### Network\n\n- **HTTP port**: default 8080.\n- **HTTPS port**: default 8443.\n- **DNS port**: default 5353.\n- **Port forwarding**: when on, inserts a `pfctl` rule mapping 80\u2192HTTP port and 443\u2192HTTPS port into `/etc/pf.conf` (correct translation-section placement; self-heals a file corrupted by older versions). Asks for sudo once. Status reflects a live 443 enforcement probe, not just file presence.\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**: **Install** adds Caddy's root cert to your System keychain (removing any stale same-name roots first); **Uninstall** removes every `Caddy Local Authority` root it added. macOS asks for your password each time.\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 **\u22EF** 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 \u2194 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: \u2026**: 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### Run a health & drift scan first (`supbuddy doctor`)\n\nWhen something's off, `supbuddy doctor` is the quickest triage. It runs a **read-only** scan of 18 checks and prints findings by severity, and many of the issues below have a matching check \u2014 an unreadable `state.json`, an untrusted CA, a wedged Caddy, port 443 not redirecting, stale duplicate CA roots, legacy CA-trust LaunchAgents poisoning public TLS, an agent config still holding a revoked MCP token, a Firefox profile pinning an old Caddy root, and leftovers from deleted projects (Docker containers/volumes, `127.0.0.N` loopback aliases, `/etc/resolver` files, MCP token files). Add `--fix` to apply the opt-in repairs after a confirmation prompt \u2014 some checks are advisory and have no auto-fix. See [System doctor](#system-doctor) for the full check list and flags.\n\n### Browser shows \"Not secure\" or certificate warning\n\nThe Caddy CA is not trusted. Open **Settings \u2192 Network \u2192 Install Certificate**. macOS will prompt for your password. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* \u2192 System keychain \u2192 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 \u2192 General \u2192 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 \u2192 **Clean up leftovers\u2026** 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 `\u2026/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 \u2192 MCP \u2192 the client's \u22EF menu \u2192 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\u2026** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\nOn **Next.js 15.3+/16**, a proxied dev request can also be blocked (e.g. a \"Cross origin request detected\" warning) because Supbuddy now passes the real browser `Origin` through rather than rewriting it, and Next validates it against `allowedDevOrigins` (which defaults to `localhost`). Add your Supbuddy domain to `allowedDevOrigins` in `next.config` \u2014 see [Next.js cross-origin dev requests](#nextjs-cross-origin-dev-requests-alloweddevorigins). This is a separate key from the Server Actions list; 15.3+/16 may need both.\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\u2026** 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### Supabase Realtime: channel reaches `SUBSCRIBED` but no `postgres_changes` events arrive\n\nIf a channel subscribes fine (and writes succeed) but change events never fire, this is almost always **realtime warmup timing right after the stack starts** \u2014 not the Supbuddy proxy. Local Realtime can accept a channel join and report `SUBSCRIBED` before its logical-replication binding for the tenant is ready, so `INSERT`/`UPDATE`s in that brief window are silently missed. Give the stack a few seconds after the Supabase tab goes green, then re-subscribe (or reconnect the channel). This is **unrelated to the `.local` domain**: Kong routes `/realtime/v1/*` by path and rewrites the upstream `Host` to its internal realtime tenant, so reaching realtime through `https://api.<project>.local` behaves identically to the raw `localhost:54321` port \u2014 forwarding the `.local` host upstream does not change tenant resolution. The new `sb_publishable_*` / `sb_secret_*` API keys also work for local realtime (Kong maps them to the legacy JWT), so you don't need to switch key formats.\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\u21928443 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\u2192on 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 forwarding is on but 443 won't connect\n\nSupbuddy reports port forwarding as **active** only when a live probe confirms 443 actually reaches Caddy \u2014 the rule being on disk isn't enough. If the rule is present but not being enforced (typically right after a reboot, or when an older Supbuddy version left `/etc/pf.conf` in a broken state), the status carries a `pf_not_enforcing` diagnostic instead of a false \"enabled\", and the banner tells you to **restart the proxy** to re-apply the redirect.\n\nOlder versions appended their `rdr-anchor` to the **end** of `/etc/pf.conf`, after Apple's filter anchor \u2014 which pf rejects, because translation rules must come before filtering rules. That silently invalidated the whole ruleset, so every later `pfctl -f` failed and 443 was dead. Current builds insert the anchor in the correct translation section and **self-heal** a file corrupted by the old version on the next proxy start. Supbuddy keeps a single stable backup at `/etc/pf.conf.supbuddy-backup` (older builds accumulated unbounded timestamped backups). If a restart doesn't fix it, inspect `/etc/pf.conf` and confirm the `rdr-anchor \"virtual.localhost\"` line sits before `anchor \"com.apple/*\"`.\n\n### Proxy came up but shows a degraded \"error\" state\n\nIf the one-time sudo prompt for port forwarding / DNS is cancelled or fails, Supbuddy no longer aborts the whole start. Caddy still starts and HTTPS keeps working on the high port (8443), and the CA is still generated; the proxy just shows an actionable **error** (degraded) state with a **Retry**. Click **Retry** and approve the sudo prompt to restore real-port (80/443) access and DNS. Until then, reach your apps on `https://<domain>:8443`.\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 \u2192 Network** / **Settings \u2192 MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nUse `supbuddy reset` (see *System reset*) \u2014 it backs up anything you can't regenerate first, and it removes the things a plain `rm -rf` leaves behind (the pf redirect, the resolver files, the loopback aliases, the trusted CA):\n\n```bash\nsupbuddy reset --tier=soft # just the app state and caches\nsupbuddy reset --tier=deep # + services, Caddy leftovers, /etc integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\n```\n\nThe manual equivalent, if the CLI isn't available \u2014 quit Supbuddy first, and note that this deletes `secrets/` and any backups under it with no copy anywhere:\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 \u2192 General \u2192 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 \u2192 MCP \u2192 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 (the full reset refuses to run while it's open, because its watchdog restarts the daemon).\n2. Run `supbuddy reset --tier=full` and type `RESET` when it asks. This backs up your project data, then removes the containers, volumes, `/etc` integrations, CA trust, repo artifacts, credentials, the start-on-login service and the app-data directory \u2014 keeping `<app-data>/backups/reset-<timestamp>/`. See *System reset*, including the short list of things it deliberately leaves behind.\n3. Drag **Supbuddy.app** from `/Applications` to the Trash, and move the backups directory somewhere safe (or delete it).\n4. If you'd rather not use the CLI: see \"Wipe everything and start over\" above for the manual equivalent, plus `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";
39358
39396
  }
39359
39397
  });
39360
39398
 
@@ -39790,10 +39828,10 @@ var init_managed_block = __esm({
39790
39828
 
39791
39829
  // ../../packages/core/project-context/write-engine.ts
39792
39830
  import fs22 from "fs/promises";
39793
- import path21 from "path";
39831
+ import path22 from "path";
39794
39832
  import crypto3 from "crypto";
39795
39833
  async function atomicWrite(absolutePath, content) {
39796
- await fs22.mkdir(path21.dirname(absolutePath), { recursive: true });
39834
+ await fs22.mkdir(path22.dirname(absolutePath), { recursive: true });
39797
39835
  const tmp = absolutePath + ".tmp." + process.pid + "." + Date.now() + "." + crypto3.randomBytes(3).toString("hex");
39798
39836
  await fs22.writeFile(tmp, content, "utf-8");
39799
39837
  await fs22.rename(tmp, absolutePath);
@@ -39863,13 +39901,13 @@ var init_write_engine = __esm({
39863
39901
 
39864
39902
  // ../../packages/core/project-context/gitignore.ts
39865
39903
  import fs23 from "fs/promises";
39866
- import path22 from "path";
39904
+ import path23 from "path";
39867
39905
  function buildGitignoreBody(localOwnedRels = []) {
39868
39906
  const editorEntries = [...new Set(localOwnedRels)].sort();
39869
39907
  return ["# Supbuddy-managed (do not commit)", ...ALWAYS_IGNORED, ...editorEntries].join("\n");
39870
39908
  }
39871
39909
  async function ensureGitignoreEntries(projectPath, localOwnedRels = []) {
39872
- const p = path22.join(projectPath, ".gitignore");
39910
+ const p = path23.join(projectPath, ".gitignore");
39873
39911
  let existing = "";
39874
39912
  try {
39875
39913
  existing = await fs23.readFile(p, "utf-8");
@@ -39881,7 +39919,7 @@ async function ensureGitignoreEntries(projectPath, localOwnedRels = []) {
39881
39919
  await fs23.writeFile(p, updated, "utf-8");
39882
39920
  }
39883
39921
  async function removeGitignoreEntries(projectPath) {
39884
- const p = path22.join(projectPath, ".gitignore");
39922
+ const p = path23.join(projectPath, ".gitignore");
39885
39923
  let existing = "";
39886
39924
  try {
39887
39925
  existing = await fs23.readFile(p, "utf-8");
@@ -39904,7 +39942,7 @@ var init_gitignore = __esm({
39904
39942
  });
39905
39943
 
39906
39944
  // ../../packages/core/project-context/capabilities.ts
39907
- import path23 from "path";
39945
+ import path24 from "path";
39908
39946
  import os9 from "os";
39909
39947
  function globalHomeDir() {
39910
39948
  return process.env.SUPBUDDY_HOME_DIR || os9.homedir();
@@ -39919,7 +39957,7 @@ function resolveTargetScope(scope, cap) {
39919
39957
  }
39920
39958
  function resolveGlobalPath(cap, homeDir) {
39921
39959
  if (!cap.globalPathParts) return null;
39922
- return path23.join(homeDir, ...cap.globalPathParts);
39960
+ return path24.join(homeDir, ...cap.globalPathParts);
39923
39961
  }
39924
39962
  var TARGET_CAPABILITIES;
39925
39963
  var init_capabilities = __esm({
@@ -39944,10 +39982,10 @@ var init_capabilities = __esm({
39944
39982
 
39945
39983
  // ../../packages/core/project-context/global-registry.ts
39946
39984
  import fs24 from "fs/promises";
39947
- import path24 from "path";
39985
+ import path25 from "path";
39948
39986
  import crypto4 from "crypto";
39949
39987
  async function registryPath() {
39950
- return path24.join(await getAppSupportDir(), "global-context.json");
39988
+ return path25.join(await getAppSupportDir(), "global-context.json");
39951
39989
  }
39952
39990
  async function loadRegistry() {
39953
39991
  const p = await registryPath();
@@ -39963,7 +40001,7 @@ async function saveRegistry(reg) {
39963
40001
  }
39964
40002
  async function rmdirLeaf(filePath) {
39965
40003
  try {
39966
- await fs24.rmdir(path24.dirname(filePath));
40004
+ await fs24.rmdir(path25.dirname(filePath));
39967
40005
  } catch {
39968
40006
  }
39969
40007
  }
@@ -40039,7 +40077,7 @@ __export(sync_manager_exports, {
40039
40077
  stopContextSync: () => stopContextSync,
40040
40078
  syncProjectNow: () => syncProjectNow
40041
40079
  });
40042
- import path25 from "path";
40080
+ import path26 from "path";
40043
40081
  import fs25 from "fs/promises";
40044
40082
  import { createRequire as createRequire2 } from "module";
40045
40083
  function effectiveScope(slot, settings) {
@@ -40172,7 +40210,7 @@ async function syncProjectNow(projectId, opts = {}) {
40172
40210
  try {
40173
40211
  const r = await ensureGlobalFile(gp, content, SUPBUDDY_VERSION, projectId);
40174
40212
  files.push({ path: gp, rel_path: gp, target: t.slot, status: r.status });
40175
- await fs25.rm(path25.join(project.path, t.rel), { force: true }).catch(() => {
40213
+ await fs25.rm(path26.join(project.path, t.rel), { force: true }).catch(() => {
40176
40214
  });
40177
40215
  delete checksums[t.rel];
40178
40216
  } catch (err) {
@@ -40187,7 +40225,7 @@ async function syncProjectNow(projectId, opts = {}) {
40187
40225
  continue;
40188
40226
  }
40189
40227
  }
40190
- const abs = path25.join(project.path, t.rel);
40228
+ const abs = path26.join(project.path, t.rel);
40191
40229
  try {
40192
40230
  let res;
40193
40231
  if (t.ownership === "managed-block") {
@@ -40243,7 +40281,7 @@ async function syncProjectNow(projectId, opts = {}) {
40243
40281
  }
40244
40282
  }
40245
40283
  if (settings.manage_gitignore) {
40246
- const gitignorePath = path25.join(project.path, ".gitignore");
40284
+ const gitignorePath = path26.join(project.path, ".gitignore");
40247
40285
  try {
40248
40286
  let before = null;
40249
40287
  try {
@@ -40270,7 +40308,7 @@ async function syncProjectNow(projectId, opts = {}) {
40270
40308
  });
40271
40309
  }
40272
40310
  }
40273
- const metaAbs = path25.join(project.path, ".supbuddy", "meta.json");
40311
+ const metaAbs = path26.join(project.path, ".supbuddy", "meta.json");
40274
40312
  const metaRel = ".supbuddy/meta.json";
40275
40313
  try {
40276
40314
  const checksumsForMeta = { ...checksums };
@@ -40656,18 +40694,18 @@ var init_dind = __esm({
40656
40694
 
40657
40695
  // ../../packages/core/system-doctor/wipe/steps/repo-artifacts.ts
40658
40696
  import fs26 from "fs/promises";
40659
- import path26 from "path";
40697
+ import path27 from "path";
40660
40698
  async function ownedEnvFiles(p, repo) {
40661
- const root = path26.resolve(repo);
40699
+ const root = path27.resolve(repo);
40662
40700
  const dirs = /* @__PURE__ */ new Set([root]);
40663
40701
  for (const app of p.apps ?? []) {
40664
- if (!app?.path || !path26.isAbsolute(app.path)) continue;
40665
- const abs = path26.resolve(app.path);
40666
- if (abs === root || abs.startsWith(`${root}${path26.sep}`)) dirs.add(abs);
40702
+ if (!app?.path || !path27.isAbsolute(app.path)) continue;
40703
+ const abs = path27.resolve(app.path);
40704
+ if (abs === root || abs.startsWith(`${root}${path27.sep}`)) dirs.add(abs);
40667
40705
  }
40668
40706
  const files = [];
40669
40707
  for (const d of dirs) {
40670
- const f = path26.join(d, ".env.supbuddy");
40708
+ const f = path27.join(d, ".env.supbuddy");
40671
40709
  try {
40672
40710
  if ((await fs26.lstat(f)).isFile()) files.push(f);
40673
40711
  } catch {
@@ -40676,9 +40714,9 @@ async function ownedEnvFiles(p, repo) {
40676
40714
  return files;
40677
40715
  }
40678
40716
  function isSafeProjectPath(p) {
40679
- if (!path26.isAbsolute(p)) return false;
40680
- const resolved = path26.resolve(p);
40681
- return resolved !== path26.parse(resolved).root;
40717
+ if (!path27.isAbsolute(p)) return false;
40718
+ const resolved = path27.resolve(p);
40719
+ return resolved !== path27.parse(resolved).root;
40682
40720
  }
40683
40721
  async function isRealDirectory(p) {
40684
40722
  try {
@@ -40698,7 +40736,7 @@ async function pathExists(p) {
40698
40736
  async function recordedBlockFiles(repo, dir) {
40699
40737
  let checksums;
40700
40738
  try {
40701
- const raw = JSON.parse(await fs26.readFile(path26.join(dir, "meta.json"), "utf-8"));
40739
+ const raw = JSON.parse(await fs26.readFile(path27.join(dir, "meta.json"), "utf-8"));
40702
40740
  checksums = raw?.checksums ?? {};
40703
40741
  } catch {
40704
40742
  return [];
@@ -40706,8 +40744,8 @@ async function recordedBlockFiles(repo, dir) {
40706
40744
  const files = [];
40707
40745
  for (const rel of Object.keys(checksums)) {
40708
40746
  if (typeof rel !== "string" || rel.startsWith(".supbuddy/")) continue;
40709
- const abs = path26.resolve(repo, rel);
40710
- if (!abs.startsWith(`${path26.resolve(repo)}${path26.sep}`)) continue;
40747
+ const abs = path27.resolve(repo, rel);
40748
+ if (!abs.startsWith(`${path27.resolve(repo)}${path27.sep}`)) continue;
40711
40749
  try {
40712
40750
  if (extractBlock(await fs26.readFile(abs, "utf-8"))) files.push(abs);
40713
40751
  } catch {
@@ -40753,16 +40791,16 @@ var init_repo_artifacts = __esm({
40753
40791
  for (const p of projectSnapshot()) {
40754
40792
  const repo = p.path;
40755
40793
  if (!repo || !isSafeProjectPath(repo)) continue;
40756
- const dir = path26.join(repo, ".supbuddy");
40794
+ const dir = path27.join(repo, ".supbuddy");
40757
40795
  const hasDir = await isRealDirectory(dir);
40758
40796
  const blocks = hasDir ? await recordedBlockFiles(repo, dir) : [];
40759
40797
  const envFiles = await ownedEnvFiles(p, repo);
40760
40798
  if (!hasDir && blocks.length === 0 && envFiles.length === 0) continue;
40761
40799
  const bits = [
40762
40800
  hasDir && `remove ${dir}/`,
40763
- envFiles.length > 0 && `delete ${envFiles.map((f) => path26.relative(repo, f)).join(", ")}`,
40801
+ envFiles.length > 0 && `delete ${envFiles.map((f) => path27.relative(repo, f)).join(", ")}`,
40764
40802
  "release the machine-global skill refs and the .gitignore block",
40765
- blocks.length > 0 && `strip the managed Supbuddy block from ${blocks.map((b) => path26.relative(repo, b)).join(", ")}`
40803
+ blocks.length > 0 && `strip the managed Supbuddy block from ${blocks.map((b) => path27.relative(repo, b)).join(", ")}`
40766
40804
  ].filter(Boolean);
40767
40805
  actions.push({
40768
40806
  label: `Clean Supbuddy artifacts from "${p.name ?? p.id}" (${repo}): ${bits.join(", ")}`,
@@ -40804,13 +40842,13 @@ var init_bundle_exporter = __esm({
40804
40842
 
40805
40843
  // ../../packages/core/cloud.ts
40806
40844
  import fs27 from "fs/promises";
40807
- import path27 from "path";
40845
+ import path28 from "path";
40808
40846
  import { execFile as execFile8 } from "child_process";
40809
40847
  import { promisify as promisify13 } from "util";
40810
40848
  async function sessionPath(dir) {
40811
- const d = dir ?? path27.join(await getAppSupportDir(), "secrets");
40849
+ const d = dir ?? path28.join(await getAppSupportDir(), "secrets");
40812
40850
  await fs27.mkdir(d, { recursive: true, mode: 448 });
40813
- return path27.join(d, "cloud-session.secret");
40851
+ return path28.join(d, "cloud-session.secret");
40814
40852
  }
40815
40853
  async function clearCloudSession(dir) {
40816
40854
  await fs27.rm(await sessionPath(dir), { force: true }).catch(() => {
@@ -40830,7 +40868,7 @@ var init_cloud = __esm({
40830
40868
 
40831
40869
  // ../../packages/core/system-doctor/wipe/steps/all-secrets.ts
40832
40870
  import fs28 from "fs/promises";
40833
- import path28 from "path";
40871
+ import path29 from "path";
40834
40872
  var allSecrets;
40835
40873
  var init_all_secrets = __esm({
40836
40874
  "../../packages/core/system-doctor/wipe/steps/all-secrets.ts"() {
@@ -40842,7 +40880,7 @@ var init_all_secrets = __esm({
40842
40880
  tiers: ["full"],
40843
40881
  destroysUserData: false,
40844
40882
  async build(ctx) {
40845
- const dir = path28.join(ctx.appSupportDir, "secrets");
40883
+ const dir = path29.join(ctx.appSupportDir, "secrets");
40846
40884
  let count;
40847
40885
  try {
40848
40886
  count = (await fs28.readdir(dir)).length;
@@ -41190,7 +41228,7 @@ __export(service_exports, {
41190
41228
  });
41191
41229
  import fs29 from "fs/promises";
41192
41230
  import os10 from "os";
41193
- import path29 from "path";
41231
+ import path30 from "path";
41194
41232
  import { fileURLToPath as fileURLToPath2 } from "url";
41195
41233
  import { spawnSync } from "child_process";
41196
41234
  function launchdPlist(o) {
@@ -41213,9 +41251,9 @@ function launchdPlist(o) {
41213
41251
  <key>KeepAlive</key>
41214
41252
  <true/>
41215
41253
  <key>StandardOutPath</key>
41216
- <string>${path29.join(o.logDir, "daemon.log")}</string>
41254
+ <string>${path30.join(o.logDir, "daemon.log")}</string>
41217
41255
  <key>StandardErrorPath</key>
41218
- <string>${path29.join(o.logDir, "daemon-error.log")}</string>
41256
+ <string>${path30.join(o.logDir, "daemon-error.log")}</string>
41219
41257
  </dict>
41220
41258
  </plist>
41221
41259
  `;
@@ -41235,16 +41273,16 @@ WantedBy=default.target
41235
41273
  `;
41236
41274
  }
41237
41275
  function launchdPlistPath() {
41238
- return path29.join(os10.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
41276
+ return path30.join(os10.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
41239
41277
  }
41240
41278
  function systemdUnitPath() {
41241
- const configHome = process.env.XDG_CONFIG_HOME ?? path29.join(os10.homedir(), ".config");
41242
- return path29.join(configHome, "systemd", "user", SYSTEMD_SERVICE);
41279
+ const configHome = process.env.XDG_CONFIG_HOME ?? path30.join(os10.homedir(), ".config");
41280
+ return path30.join(configHome, "systemd", "user", SYSTEMD_SERVICE);
41243
41281
  }
41244
41282
  function resolveBinPath() {
41245
- const __dirname3 = path29.dirname(fileURLToPath2(import.meta.url));
41246
- const repoRoot = path29.resolve(__dirname3, "..", "..", "..");
41247
- return path29.join(repoRoot, "apps", "cli", "dist", "bin.js");
41283
+ const __dirname3 = path30.dirname(fileURLToPath2(import.meta.url));
41284
+ const repoRoot = path30.resolve(__dirname3, "..", "..", "..");
41285
+ return path30.join(repoRoot, "apps", "cli", "dist", "bin.js");
41248
41286
  }
41249
41287
  async function installService(opts = {}, run = defaultRunner2) {
41250
41288
  const platform = process.platform;
@@ -41264,11 +41302,11 @@ Run \`yarn workspace supbuddy build\` first.`
41264
41302
  return 1;
41265
41303
  }
41266
41304
  const stateDir = opts.stateDir ?? defaultStateDir();
41267
- const logDir = path29.join(stateDir, "logs");
41305
+ const logDir = path30.join(stateDir, "logs");
41268
41306
  await fs29.mkdir(logDir, { recursive: true });
41269
41307
  if (platform === "darwin") {
41270
41308
  const plistPath = launchdPlistPath();
41271
- await fs29.mkdir(path29.dirname(plistPath), { recursive: true });
41309
+ await fs29.mkdir(path30.dirname(plistPath), { recursive: true });
41272
41310
  const content2 = launchdPlist({ label: LAUNCHD_LABEL, nodePath, binPath, stateDir, logDir });
41273
41311
  await fs29.writeFile(plistPath, content2, { encoding: "utf8", mode: 420 });
41274
41312
  run("launchctl", ["unload", plistPath]);
@@ -41281,7 +41319,7 @@ Run \`yarn workspace supbuddy build\` first.`
41281
41319
  return 0;
41282
41320
  }
41283
41321
  const unitPath = systemdUnitPath();
41284
- await fs29.mkdir(path29.dirname(unitPath), { recursive: true });
41322
+ await fs29.mkdir(path30.dirname(unitPath), { recursive: true });
41285
41323
  const content = systemdUnit({ label: LAUNCHD_LABEL, nodePath, binPath, stateDir, logDir });
41286
41324
  await fs29.writeFile(unitPath, content, { encoding: "utf8", mode: 420 });
41287
41325
  const reload = run("systemctl", ["--user", "daemon-reload"]);
@@ -41401,10 +41439,10 @@ __export(reset_full_exports, {
41401
41439
  removeAppDataPreservingBackups: () => removeAppDataPreservingBackups
41402
41440
  });
41403
41441
  import fsp from "fs/promises";
41404
- import path30 from "path";
41442
+ import path31 from "path";
41405
41443
  import { spawnSync as spawnSync2 } from "child_process";
41406
41444
  async function removeAppDataPreservingBackups(appSupportDir, io2 = defaultRemoveIo) {
41407
- if (!appSupportDir || !path30.isAbsolute(appSupportDir)) {
41445
+ if (!appSupportDir || !path31.isAbsolute(appSupportDir)) {
41408
41446
  throw new Error(`refusing to empty "${appSupportDir}": not an absolute app-data directory path`);
41409
41447
  }
41410
41448
  const entries = await io2.readdir(appSupportDir);
@@ -41420,7 +41458,7 @@ async function removeAppDataPreservingBackups(appSupportDir, io2 = defaultRemove
41420
41458
  preserved.push(name);
41421
41459
  continue;
41422
41460
  }
41423
- await io2.rm(path30.join(appSupportDir, name), { recursive: true, force: true });
41461
+ await io2.rm(path31.join(appSupportDir, name), { recursive: true, force: true });
41424
41462
  removed.push(name);
41425
41463
  }
41426
41464
  const left = (await io2.readdir(appSupportDir)).filter((n) => n !== BACKUPS_DIRNAME);
@@ -41431,7 +41469,7 @@ async function removeAppDataPreservingBackups(appSupportDir, io2 = defaultRemove
41431
41469
  }
41432
41470
  return {
41433
41471
  appSupportDir,
41434
- backupsDir: path30.join(appSupportDir, BACKUPS_DIRNAME),
41472
+ backupsDir: path31.join(appSupportDir, BACKUPS_DIRNAME),
41435
41473
  removed,
41436
41474
  preserved
41437
41475
  };
@@ -41449,7 +41487,7 @@ function defaultProcessProbe(cmd, args) {
41449
41487
  }
41450
41488
  }
41451
41489
  async function hydrateStoreFromDisk(appSupportDir, useStore3) {
41452
- const raw = await fsp.readFile(path30.join(appSupportDir, "state.json"), "utf8").catch(() => null);
41490
+ const raw = await fsp.readFile(path31.join(appSupportDir, "state.json"), "utf8").catch(() => null);
41453
41491
  if (!raw) return null;
41454
41492
  let data;
41455
41493
  try {
@@ -42488,10 +42526,10 @@ __export(install_exports, {
42488
42526
  });
42489
42527
  import { spawnSync as spawnSync3 } from "child_process";
42490
42528
  import readline2 from "readline";
42491
- import path31 from "path";
42529
+ import path32 from "path";
42492
42530
  function isEphemeralNpx() {
42493
42531
  const argv1 = process.argv[1] || "";
42494
- if (argv1.includes(`${path31.sep}_npx${path31.sep}`) || argv1.includes("/_npx/")) return true;
42532
+ if (argv1.includes(`${path32.sep}_npx${path32.sep}`) || argv1.includes("/_npx/")) return true;
42495
42533
  if (process.env.npm_command === "exec") return true;
42496
42534
  return false;
42497
42535
  }
@@ -42566,7 +42604,7 @@ import net from "net";
42566
42604
  import http2 from "http";
42567
42605
  import fs30 from "fs/promises";
42568
42606
  import os11 from "os";
42569
- import path32 from "path";
42607
+ import path33 from "path";
42570
42608
  function getFreePort() {
42571
42609
  return new Promise((resolve, reject) => {
42572
42610
  const srv = net.createServer();
@@ -42650,7 +42688,7 @@ async function main() {
42650
42688
  const workerPort = await getFreePort();
42651
42689
  let mcpPort = await getFreePort();
42652
42690
  if (mcpPort === workerPort) mcpPort = await getFreePort();
42653
- const stateDir = await fs30.mkdtemp(path32.join(os11.tmpdir(), "supbuddy-selftest-"));
42691
+ const stateDir = await fs30.mkdtemp(path33.join(os11.tmpdir(), "supbuddy-selftest-"));
42654
42692
  const seed = {
42655
42693
  projects: [],
42656
42694
  mappings: [],
@@ -42660,7 +42698,7 @@ async function main() {
42660
42698
  mcp: { enabled: true, port: mcpPort, audit_cap: 5e3, trash_ttl_days: 7 }
42661
42699
  }
42662
42700
  };
42663
- await fs30.writeFile(path32.join(stateDir, "state.json"), JSON.stringify(seed, null, 2));
42701
+ await fs30.writeFile(path33.join(stateDir, "state.json"), JSON.stringify(seed, null, 2));
42664
42702
  console.log(`isolated state dir: ${stateDir}`);
42665
42703
  console.log(`worker (Socket.IO) port: ${workerPort} MCP port: ${mcpPort}
42666
42704
  `);
@@ -42712,7 +42750,7 @@ import https from "https";
42712
42750
  import fs31 from "fs/promises";
42713
42751
  import { createWriteStream as createWriteStream2 } from "fs";
42714
42752
  import os12 from "os";
42715
- import path33 from "path";
42753
+ import path34 from "path";
42716
42754
  import crypto5 from "crypto";
42717
42755
  import readline3 from "readline";
42718
42756
  import { execFile as execFile9, execFileSync as execFileSync3, spawn as spawn8 } from "child_process";
@@ -42838,7 +42876,7 @@ function sq(p) {
42838
42876
  return `'${p.replace(/'/g, "'\\''")}'`;
42839
42877
  }
42840
42878
  async function swapApp(newApp) {
42841
- const dir = path33.dirname(INSTALLED_APP);
42879
+ const dir = path34.dirname(INSTALLED_APP);
42842
42880
  if (await canWrite(dir)) {
42843
42881
  const bak = `${INSTALLED_APP}.bak-${process.pid}`;
42844
42882
  if (await fs31.stat(INSTALLED_APP).then(() => true).catch(() => false)) {
@@ -42913,10 +42951,10 @@ supbuddy update: could not reach the release server \u2014 ${e.message}`);
42913
42951
  return 0;
42914
42952
  }
42915
42953
  }
42916
- const tmp = await fs31.mkdtemp(path33.join(os12.tmpdir(), "supbuddy-update-"));
42917
- const tar = path33.join(tmp, TAR_ASSET);
42918
- const shaFile = path33.join(tmp, SHA_ASSET);
42919
- const extractDir = path33.join(tmp, "extracted");
42954
+ const tmp = await fs31.mkdtemp(path34.join(os12.tmpdir(), "supbuddy-update-"));
42955
+ const tar = path34.join(tmp, TAR_ASSET);
42956
+ const shaFile = path34.join(tmp, SHA_ASSET);
42957
+ const extractDir = path34.join(tmp, "extracted");
42920
42958
  try {
42921
42959
  console.log("Downloading\u2026");
42922
42960
  await downloadAsset(pick.tarUrl, tar);
@@ -42930,7 +42968,7 @@ supbuddy update: could not reach the release server \u2014 ${e.message}`);
42930
42968
  }
42931
42969
  await fs31.mkdir(extractDir, { recursive: true });
42932
42970
  execFileSync3("tar", ["-xzf", tar, "-C", extractDir]);
42933
- const newApp = path33.join(extractDir, "Supbuddy.app");
42971
+ const newApp = path34.join(extractDir, "Supbuddy.app");
42934
42972
  if (!await fs31.stat(newApp).then(() => true).catch(() => false)) {
42935
42973
  console.error("supbuddy update: the archive did not contain Supbuddy.app.");
42936
42974
  return 1;
@@ -43033,11 +43071,11 @@ __export(run_exports, {
43033
43071
  });
43034
43072
  import { spawn as spawn9, execSync as execSync2 } from "child_process";
43035
43073
  import fs32 from "fs";
43036
- import path34 from "path";
43074
+ import path35 from "path";
43037
43075
  function readMeta(startDir) {
43038
43076
  let dir = startDir;
43039
43077
  for (; ; ) {
43040
- const metaPath = path34.join(dir, ".supbuddy", "meta.json");
43078
+ const metaPath = path35.join(dir, ".supbuddy", "meta.json");
43041
43079
  if (fs32.existsSync(metaPath)) {
43042
43080
  try {
43043
43081
  return JSON.parse(fs32.readFileSync(metaPath, "utf-8"));
@@ -43045,7 +43083,7 @@ function readMeta(startDir) {
43045
43083
  return {};
43046
43084
  }
43047
43085
  }
43048
- const parent = path34.dirname(dir);
43086
+ const parent = path35.dirname(dir);
43049
43087
  if (parent === dir) return {};
43050
43088
  dir = parent;
43051
43089
  }
@@ -43179,7 +43217,7 @@ init_state_dir();
43179
43217
  import { realpathSync } from "fs";
43180
43218
  import { spawnSync as spawnSync4 } from "child_process";
43181
43219
  import { fileURLToPath as fileURLToPath3 } from "url";
43182
- import path35 from "path";
43220
+ import path36 from "path";
43183
43221
 
43184
43222
  // src/commands.ts
43185
43223
  init_client();
@@ -44245,12 +44283,12 @@ async function runCommand(argv, flags, clientFactory) {
44245
44283
  return 0;
44246
44284
  }
44247
44285
  if (sub === "env-read") {
44248
- const path36 = rest[0];
44249
- if (!path36) {
44286
+ const path37 = rest[0];
44287
+ if (!path37) {
44250
44288
  console.error("usage: supbuddy project env-read <path> [--key K]");
44251
44289
  return 1;
44252
44290
  }
44253
- const args = { path: path36 };
44291
+ const args = { path: path37 };
44254
44292
  if (typeof flags.key === "string") args.key = flags.key;
44255
44293
  print(await client.call("read_env_file", args), flags);
44256
44294
  return 0;
@@ -44510,13 +44548,10 @@ async function runCommand(argv, flags, clientFactory) {
44510
44548
  print(await client.call("get_ca_status", {}), flags);
44511
44549
  return 0;
44512
44550
  }
44513
- if (sub === "install") {
44514
- print(await client.call("install_ca", {}), flags);
44515
- return 0;
44516
- }
44517
- if (sub === "uninstall") {
44518
- print(await client.call("uninstall_ca", {}), flags);
44519
- return 0;
44551
+ if (sub === "install" || sub === "uninstall") {
44552
+ const r = await client.call(sub === "install" ? "install_ca" : "uninstall_ca", {});
44553
+ print(r, flags);
44554
+ return r?.success === false ? 1 : 0;
44520
44555
  }
44521
44556
  console.error("usage: supbuddy ca status|install|uninstall");
44522
44557
  return 1;
@@ -44534,8 +44569,8 @@ async function runCommand(argv, flags, clientFactory) {
44534
44569
  return 0;
44535
44570
  }
44536
44571
  if (sub === "write") {
44537
- const [path36, ...pairs2] = rest;
44538
- if (!path36 || pairs2.length === 0) {
44572
+ const [path37, ...pairs2] = rest;
44573
+ if (!path37 || pairs2.length === 0) {
44539
44574
  console.error("usage: supbuddy env write <path> <key=val>...");
44540
44575
  return 1;
44541
44576
  }
@@ -44550,7 +44585,7 @@ async function runCommand(argv, flags, clientFactory) {
44550
44585
  const v = pair.slice(eqIdx + 1);
44551
44586
  patch[k] = v === "" ? null : v;
44552
44587
  }
44553
- print(await client.call("write_env_file", { path: path36, patch }), flags);
44588
+ print(await client.call("write_env_file", { path: path37, patch }), flags);
44554
44589
  return 0;
44555
44590
  }
44556
44591
  console.error("usage: supbuddy env copy|write ...");
@@ -45075,9 +45110,9 @@ async function runShell(opts, stateDir, module) {
45075
45110
  const os13 = await import("os");
45076
45111
  const fs33 = await import("fs");
45077
45112
  const shellEntry = fileURLToPath3(new URL("../../tui/src/shell/bin.tsx", import.meta.url));
45078
- const REPO_ROOT2 = path35.resolve(fileURLToPath3(import.meta.url), "..", "..", "..", "..");
45079
- const tsxBin = path35.join(REPO_ROOT2, "node_modules", ".bin", "tsx");
45080
- const resultFile = path35.join(os13.tmpdir(), `supbuddy-shell-${process.pid}-${Date.now()}.json`);
45113
+ const REPO_ROOT2 = path36.resolve(fileURLToPath3(import.meta.url), "..", "..", "..", "..");
45114
+ const tsxBin = path36.join(REPO_ROOT2, "node_modules", ".bin", "tsx");
45115
+ const resultFile = path36.join(os13.tmpdir(), `supbuddy-shell-${process.pid}-${Date.now()}.json`);
45081
45116
  const env3 = { ...process.env, SUPBUDDY_SHELL_RESULT: resultFile };
45082
45117
  if (stateDir) env3.SUPBUDDY_STATE_DIR = stateDir;
45083
45118
  if (module) env3.SUPBUDDY_SHELL_MODULE = module;
@@ -45174,8 +45209,8 @@ async function dispatch(argv, opts) {
45174
45209
  case "tui":
45175
45210
  case "dash": {
45176
45211
  const tuiEntry = fileURLToPath3(new URL("../../tui/src/bin.tsx", import.meta.url));
45177
- const REPO_ROOT2 = path35.resolve(fileURLToPath3(import.meta.url), "..", "..", "..", "..");
45178
- const tsxBin = path35.join(REPO_ROOT2, "node_modules", ".bin", "tsx");
45212
+ const REPO_ROOT2 = path36.resolve(fileURLToPath3(import.meta.url), "..", "..", "..", "..");
45213
+ const tsxBin = path36.join(REPO_ROOT2, "node_modules", ".bin", "tsx");
45179
45214
  const env3 = { ...process.env };
45180
45215
  if (typeof flags["state-dir"] === "string") env3.SUPBUDDY_STATE_DIR = flags["state-dir"];
45181
45216
  if (typeof flags["url"] === "string") env3.SUPBUDDY_DAEMON_URL = flags["url"];