supbuddy 3.1.24 → 3.2.0
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 +51 -6
- package/dist/daemon/worker.cjs +731 -97
- package/package.json +10 -2
package/dist/daemon/worker.cjs
CHANGED
|
@@ -41,14 +41,15 @@ const https$3 = require("https");
|
|
|
41
41
|
const nodeProcess = require("node:process");
|
|
42
42
|
const node_child_process = require("node:child_process");
|
|
43
43
|
const promises = require("node:fs/promises");
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
44
|
+
const os$2 = require("node:os");
|
|
45
|
+
const path$1 = require("node:path");
|
|
46
|
+
const fs$1 = require("node:fs");
|
|
47
47
|
const promises$1 = require("node:timers/promises");
|
|
48
|
+
const require$$0$3 = require("node:util");
|
|
49
|
+
const node_module = require("node:module");
|
|
48
50
|
const require$$0$2 = require("buffer");
|
|
49
51
|
const dgram$1 = require("dgram");
|
|
50
52
|
const require$$0$4 = require("node:dgram");
|
|
51
|
-
const require$$0$3 = require("node:util");
|
|
52
53
|
const require$$1$2 = require("node:crypto");
|
|
53
54
|
const require$$1$3 = require("node:net");
|
|
54
55
|
const require$$0$5 = require("node:http");
|
|
@@ -20021,7 +20022,7 @@ const require$$18 = {
|
|
|
20021
20022
|
const zlib_12 = require$$1;
|
|
20022
20023
|
const accepts2 = accepts$2;
|
|
20023
20024
|
const stream_1 = require$$4;
|
|
20024
|
-
const path$
|
|
20025
|
+
const path$12 = path;
|
|
20025
20026
|
const engine_io_1 = require$$6$1;
|
|
20026
20027
|
const client_1 = client;
|
|
20027
20028
|
const events_12 = require$$0$1;
|
|
@@ -20216,7 +20217,7 @@ const require$$18 = {
|
|
|
20216
20217
|
res.writeHeader("cache-control", "public, max-age=0");
|
|
20217
20218
|
res.writeHeader("content-type", "application/" + (isMap ? "json" : "javascript") + "; charset=utf-8");
|
|
20218
20219
|
res.writeHeader("etag", expectedEtag);
|
|
20219
|
-
const filepath = path$
|
|
20220
|
+
const filepath = path$12.join(__dirname, "../client-dist/", filename);
|
|
20220
20221
|
(0, uws_1.serveFile)(res, filepath);
|
|
20221
20222
|
});
|
|
20222
20223
|
}
|
|
@@ -20298,7 +20299,7 @@ const require$$18 = {
|
|
|
20298
20299
|
* @private
|
|
20299
20300
|
*/
|
|
20300
20301
|
static sendFile(filename, req, res) {
|
|
20301
|
-
const readStream2 = (0, fs_12.createReadStream)(path$
|
|
20302
|
+
const readStream2 = (0, fs_12.createReadStream)(path$12.join(__dirname, "../client-dist/", filename));
|
|
20302
20303
|
const encoding3 = accepts2(req).encodings(["br", "gzip", "deflate"]);
|
|
20303
20304
|
const onError = (err) => {
|
|
20304
20305
|
if (err) {
|
|
@@ -21408,6 +21409,11 @@ const MCP_TOOL_FEATURES = {
|
|
|
21408
21409
|
push_to_cloud: "pro_writes",
|
|
21409
21410
|
cloud_teardown: "pro_writes",
|
|
21410
21411
|
cloud_sign_in: "pro_writes",
|
|
21412
|
+
cloud_sync_start: "pro_writes",
|
|
21413
|
+
cloud_sync_stop: "pro_writes",
|
|
21414
|
+
// Reading sync state is free, like get_cloud_status — knowing whether your files are in sync should
|
|
21415
|
+
// never be the thing behind a paywall.
|
|
21416
|
+
cloud_sync_status: "free",
|
|
21411
21417
|
scan_project: "pro_writes",
|
|
21412
21418
|
update_settings: "pro_writes",
|
|
21413
21419
|
copy_env_var: "pro_env_values",
|
|
@@ -25505,6 +25511,12 @@ const MCP_TOOL_SCHEMAS = {
|
|
|
25505
25511
|
push_to_cloud: objectType({ project_id: ID, github_repo: stringType().min(1).optional(), force: booleanType().optional(), supabase_services: enumType(["minimal", "full"]).optional() }).strict(),
|
|
25506
25512
|
cloud_teardown: objectType({ project_id: ID }).strict(),
|
|
25507
25513
|
cloud_sign_in: objectType({ email: stringType().email(), password: stringType().min(1) }).strict(),
|
|
25514
|
+
// `authority` has NO default and is not optional: the first sync pass is one-way, so the named side
|
|
25515
|
+
// overwrites the other. A schema default here would reintroduce exactly the guess every layer of the
|
|
25516
|
+
// sync code refuses to make.
|
|
25517
|
+
cloud_sync_start: objectType({ project_id: ID, authority: enumType(["local", "cloud"]) }).strict(),
|
|
25518
|
+
cloud_sync_status: objectType({ project_id: ID }).strict(),
|
|
25519
|
+
cloud_sync_stop: objectType({ project_id: ID }).strict(),
|
|
25508
25520
|
update_settings: objectType({ patch: recordType(stringType(), unknownType()) }).strict(),
|
|
25509
25521
|
restore_mapping: objectType({ id: ID }).strict(),
|
|
25510
25522
|
restore_project: objectType({ id: ID, restore_children: booleanType().default(true) }).strict(),
|
|
@@ -25921,10 +25933,10 @@ function parseAddonsExtension(composeData) {
|
|
|
25921
25933
|
if (block == null) return null;
|
|
25922
25934
|
return SupbuddyAddonsExtensionSchema.parse(block);
|
|
25923
25935
|
}
|
|
25924
|
-
var define_process_env_default$
|
|
25936
|
+
var define_process_env_default$g = {};
|
|
25925
25937
|
const WORKER_PORT_ENV = "WORKER_PORT";
|
|
25926
25938
|
const DEFAULT_WORKER_PORT = 48760;
|
|
25927
|
-
function resolveWorkerPort(env = typeof process !== "undefined" ? define_process_env_default$
|
|
25939
|
+
function resolveWorkerPort(env = typeof process !== "undefined" ? define_process_env_default$g : {}) {
|
|
25928
25940
|
const raw = env[WORKER_PORT_ENV];
|
|
25929
25941
|
if (raw !== void 0 && raw !== "") {
|
|
25930
25942
|
const n = Number(raw);
|
|
@@ -26103,7 +26115,7 @@ function planGlobalTldCascade(projects, mappings, newTld) {
|
|
|
26103
26115
|
});
|
|
26104
26116
|
return { projectUpdates, mappingUpdates };
|
|
26105
26117
|
}
|
|
26106
|
-
var define_process_env_default$
|
|
26118
|
+
var define_process_env_default$f = {};
|
|
26107
26119
|
function coerceProjectName(name2, fallback = "Untitled project") {
|
|
26108
26120
|
if (typeof name2 === "string") return name2;
|
|
26109
26121
|
if (name2 && typeof name2.name === "string") return name2.name;
|
|
@@ -26600,12 +26612,12 @@ function schedulePersist() {
|
|
|
26600
26612
|
}, 1e3);
|
|
26601
26613
|
}
|
|
26602
26614
|
async function getAppSupportDir() {
|
|
26603
|
-
const override = define_process_env_default$
|
|
26615
|
+
const override = define_process_env_default$f.SUPBUDDY_STATE_DIR;
|
|
26604
26616
|
if (override) {
|
|
26605
26617
|
await fs.mkdir(override, { recursive: true });
|
|
26606
26618
|
return override;
|
|
26607
26619
|
}
|
|
26608
|
-
if (define_process_env_default$
|
|
26620
|
+
if (define_process_env_default$f.VITEST || false) {
|
|
26609
26621
|
const testDir = path.join(os$1.tmpdir(), `supbuddy-vitest-${process.pid}`);
|
|
26610
26622
|
await fs.mkdir(testDir, { recursive: true });
|
|
26611
26623
|
return testDir;
|
|
@@ -26615,9 +26627,9 @@ async function getAppSupportDir() {
|
|
|
26615
26627
|
if (platform === "darwin") {
|
|
26616
26628
|
userDataPath = path.join(os$1.homedir(), "Library", "Application Support", "Supbuddy");
|
|
26617
26629
|
} else if (platform === "win32") {
|
|
26618
|
-
userDataPath = path.join(define_process_env_default$
|
|
26630
|
+
userDataPath = path.join(define_process_env_default$f.APPDATA || path.join(os$1.homedir(), "AppData", "Roaming"), "Supbuddy");
|
|
26619
26631
|
} else {
|
|
26620
|
-
userDataPath = path.join(define_process_env_default$
|
|
26632
|
+
userDataPath = path.join(define_process_env_default$f.XDG_CONFIG_HOME || path.join(os$1.homedir(), ".config"), "Supbuddy");
|
|
26621
26633
|
}
|
|
26622
26634
|
await fs.mkdir(userDataPath, { recursive: true });
|
|
26623
26635
|
return userDataPath;
|
|
@@ -26834,7 +26846,7 @@ function planProjectActivation(input) {
|
|
|
26834
26846
|
releaseGlobalRefs: input.enabledChanged && !input.enabled
|
|
26835
26847
|
};
|
|
26836
26848
|
}
|
|
26837
|
-
var define_process_env_default$
|
|
26849
|
+
var define_process_env_default$e = {};
|
|
26838
26850
|
async function getCaddyfileDir() {
|
|
26839
26851
|
const platform = process.platform;
|
|
26840
26852
|
let userDataPath;
|
|
@@ -26842,12 +26854,12 @@ async function getCaddyfileDir() {
|
|
|
26842
26854
|
userDataPath = path.join(os$1.homedir(), "Library", "Application Support", "Supbuddy");
|
|
26843
26855
|
} else if (platform === "win32") {
|
|
26844
26856
|
userDataPath = path.join(
|
|
26845
|
-
define_process_env_default$
|
|
26857
|
+
define_process_env_default$e.APPDATA || path.join(os$1.homedir(), "AppData", "Roaming"),
|
|
26846
26858
|
"Supbuddy"
|
|
26847
26859
|
);
|
|
26848
26860
|
} else {
|
|
26849
26861
|
userDataPath = path.join(
|
|
26850
|
-
define_process_env_default$
|
|
26862
|
+
define_process_env_default$e.XDG_CONFIG_HOME || path.join(os$1.homedir(), ".config"),
|
|
26851
26863
|
"Supbuddy"
|
|
26852
26864
|
);
|
|
26853
26865
|
}
|
|
@@ -27005,7 +27017,7 @@ async function getCaddyDataDir$1() {
|
|
|
27005
27017
|
await fs.mkdir(dataDir, { recursive: true });
|
|
27006
27018
|
return dataDir;
|
|
27007
27019
|
}
|
|
27008
|
-
const execFileAsync$
|
|
27020
|
+
const execFileAsync$6 = util$1.promisify(child_process.execFile);
|
|
27009
27021
|
const DOCKER_TIMEOUT = 6e4;
|
|
27010
27022
|
function slugifyProjectName(name2) {
|
|
27011
27023
|
const slug = (name2 || "").toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 30);
|
|
@@ -27027,7 +27039,7 @@ function buildContainerName(projectId) {
|
|
|
27027
27039
|
async function getHostPortForDind(projectId, containerPort) {
|
|
27028
27040
|
const name2 = buildContainerName(projectId);
|
|
27029
27041
|
try {
|
|
27030
|
-
const { stdout } = await execFileAsync$
|
|
27042
|
+
const { stdout } = await execFileAsync$6(
|
|
27031
27043
|
"docker",
|
|
27032
27044
|
["port", name2, `${containerPort}/tcp`],
|
|
27033
27045
|
{ timeout: 5e3 }
|
|
@@ -27041,7 +27053,7 @@ async function getHostPortForDind(projectId, containerPort) {
|
|
|
27041
27053
|
async function listPublishedDindPorts(projectId) {
|
|
27042
27054
|
const name2 = buildContainerName(projectId);
|
|
27043
27055
|
try {
|
|
27044
|
-
const { stdout } = await execFileAsync$
|
|
27056
|
+
const { stdout } = await execFileAsync$6("docker", ["port", name2], { timeout: 5e3 });
|
|
27045
27057
|
const ports = /* @__PURE__ */ new Set();
|
|
27046
27058
|
for (const line of stdout.split("\n")) {
|
|
27047
27059
|
const m = line.match(/^(\d+)\/tcp\b/);
|
|
@@ -27054,7 +27066,7 @@ async function listPublishedDindPorts(projectId) {
|
|
|
27054
27066
|
}
|
|
27055
27067
|
async function isDockerAvailable() {
|
|
27056
27068
|
try {
|
|
27057
|
-
await execFileAsync$
|
|
27069
|
+
await execFileAsync$6("docker", ["info"], { timeout: 1e4 });
|
|
27058
27070
|
return true;
|
|
27059
27071
|
} catch {
|
|
27060
27072
|
return false;
|
|
@@ -27063,11 +27075,11 @@ async function isDockerAvailable() {
|
|
|
27063
27075
|
async function startDind(projectId) {
|
|
27064
27076
|
const name2 = buildContainerName(projectId);
|
|
27065
27077
|
console.log(`[DinD] Starting container ${name2}...`);
|
|
27066
|
-
await execFileAsync$
|
|
27078
|
+
await execFileAsync$6("docker", ["start", name2], { timeout: DOCKER_TIMEOUT });
|
|
27067
27079
|
const deadline = Date.now() + 3e4;
|
|
27068
27080
|
while (Date.now() < deadline) {
|
|
27069
27081
|
try {
|
|
27070
|
-
await execFileAsync$
|
|
27082
|
+
await execFileAsync$6("docker", ["exec", name2, "docker", "info"], { timeout: 5e3 });
|
|
27071
27083
|
return;
|
|
27072
27084
|
} catch {
|
|
27073
27085
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
@@ -27078,14 +27090,14 @@ async function startDind(projectId) {
|
|
|
27078
27090
|
async function stopDind(projectId) {
|
|
27079
27091
|
const name2 = buildContainerName(projectId);
|
|
27080
27092
|
console.log(`[DinD] Stopping container ${name2}...`);
|
|
27081
|
-
await execFileAsync$
|
|
27093
|
+
await execFileAsync$6("docker", ["stop", name2], { timeout: DOCKER_TIMEOUT });
|
|
27082
27094
|
}
|
|
27083
27095
|
async function deleteDind(projectId, removeData = false) {
|
|
27084
27096
|
const name2 = buildContainerName(projectId);
|
|
27085
27097
|
console.log(`[DinD] Deleting container ${name2}...`);
|
|
27086
|
-
await execFileAsync$
|
|
27098
|
+
await execFileAsync$6("docker", ["rm", "-f", name2], { timeout: 3e4 });
|
|
27087
27099
|
if (removeData) {
|
|
27088
|
-
await execFileAsync$
|
|
27100
|
+
await execFileAsync$6("docker", ["volume", "rm", "-f", `${name2}-docker`], { timeout: 1e4 }).catch(() => {
|
|
27089
27101
|
});
|
|
27090
27102
|
}
|
|
27091
27103
|
}
|
|
@@ -27094,7 +27106,7 @@ async function getDindCrashDiagnosis(projectId) {
|
|
|
27094
27106
|
let exited = false;
|
|
27095
27107
|
let exitCode = null;
|
|
27096
27108
|
try {
|
|
27097
|
-
const { stdout } = await execFileAsync$
|
|
27109
|
+
const { stdout } = await execFileAsync$6(
|
|
27098
27110
|
"docker",
|
|
27099
27111
|
["inspect", "--format", "{{.State.Status}}|{{.State.ExitCode}}", name2],
|
|
27100
27112
|
{ timeout: 5e3 }
|
|
@@ -27111,7 +27123,7 @@ async function getDindCrashDiagnosis(projectId) {
|
|
|
27111
27123
|
}
|
|
27112
27124
|
let logsTail = "";
|
|
27113
27125
|
try {
|
|
27114
|
-
const { stdout, stderr } = await execFileAsync$
|
|
27126
|
+
const { stdout, stderr } = await execFileAsync$6(
|
|
27115
27127
|
"docker",
|
|
27116
27128
|
["logs", "--tail", "50", name2],
|
|
27117
27129
|
{ timeout: 5e3, maxBuffer: 1024 * 1024 }
|
|
@@ -27143,7 +27155,7 @@ function classifyDindLogs(logsTail, meta) {
|
|
|
27143
27155
|
async function getDindStatus(projectId) {
|
|
27144
27156
|
const name2 = buildContainerName(projectId);
|
|
27145
27157
|
try {
|
|
27146
|
-
const { stdout } = await execFileAsync$
|
|
27158
|
+
const { stdout } = await execFileAsync$6(
|
|
27147
27159
|
"docker",
|
|
27148
27160
|
["inspect", "--format", "{{json .State}}", name2],
|
|
27149
27161
|
{ timeout: 1e4 }
|
|
@@ -27172,7 +27184,7 @@ async function getDindStatus(projectId) {
|
|
|
27172
27184
|
}
|
|
27173
27185
|
async function listDinds() {
|
|
27174
27186
|
try {
|
|
27175
|
-
const { stdout } = await execFileAsync$
|
|
27187
|
+
const { stdout } = await execFileAsync$6(
|
|
27176
27188
|
"docker",
|
|
27177
27189
|
["ps", "-a", "--filter", "name=supbuddy-", "--format", "{{json .}}"],
|
|
27178
27190
|
{ timeout: 1e4 }
|
|
@@ -27191,7 +27203,7 @@ async function listDinds() {
|
|
|
27191
27203
|
}
|
|
27192
27204
|
async function execInDind(projectId, command, timeoutMs = 6e4) {
|
|
27193
27205
|
const name2 = buildContainerName(projectId);
|
|
27194
|
-
const { stdout, stderr } = await execFileAsync$
|
|
27206
|
+
const { stdout, stderr } = await execFileAsync$6(
|
|
27195
27207
|
"docker",
|
|
27196
27208
|
["exec", name2, ...command],
|
|
27197
27209
|
{ timeout: timeoutMs }
|
|
@@ -28278,7 +28290,7 @@ function isMappingServeable(mapping, getProject) {
|
|
|
28278
28290
|
const project = getProject(mapping.projectId);
|
|
28279
28291
|
return !!project && project.enabled === true;
|
|
28280
28292
|
}
|
|
28281
|
-
var define_process_env_default$
|
|
28293
|
+
var define_process_env_default$d = {};
|
|
28282
28294
|
let caddyProcess = null;
|
|
28283
28295
|
let stdoutBuffer = "";
|
|
28284
28296
|
let stderrBuffer = "";
|
|
@@ -28415,9 +28427,9 @@ async function doStartCaddyServer() {
|
|
|
28415
28427
|
console.log(`[Caddy] Config: ${caddyfilePath}`);
|
|
28416
28428
|
console.log(`[Caddy] Data directory: ${dataDir}`);
|
|
28417
28429
|
const isDev = false;
|
|
28418
|
-
const homeDir = define_process_env_default$
|
|
28430
|
+
const homeDir = define_process_env_default$d.HOME || require("os").homedir();
|
|
28419
28431
|
const caddyEnv = {
|
|
28420
|
-
...define_process_env_default$
|
|
28432
|
+
...define_process_env_default$d,
|
|
28421
28433
|
HOME: homeDir,
|
|
28422
28434
|
XDG_DATA_HOME: dataDir,
|
|
28423
28435
|
XDG_CONFIG_HOME: path.dirname(caddyfilePath)
|
|
@@ -28662,6 +28674,12 @@ function proxyExpectedRunning() {
|
|
|
28662
28674
|
const s = useStore.getState().proxyState.status;
|
|
28663
28675
|
return s === "running" || s === "recovering" || s === "starting";
|
|
28664
28676
|
}
|
|
28677
|
+
async function probeCaddyTlsEitherFamily(opts, probe2 = probeCaddyTls) {
|
|
28678
|
+
const results = await Promise.all(
|
|
28679
|
+
["::1", "127.0.0.1"].map((host) => probe2({ host, ...opts }))
|
|
28680
|
+
);
|
|
28681
|
+
return results.find((r) => r.ok) ?? results[0];
|
|
28682
|
+
}
|
|
28665
28683
|
function probeCaddyTls(opts) {
|
|
28666
28684
|
return new Promise((resolve) => {
|
|
28667
28685
|
let settled = false;
|
|
@@ -28763,8 +28781,7 @@ async function healthTick(cfg) {
|
|
|
28763
28781
|
}
|
|
28764
28782
|
const target = pickProbeTarget();
|
|
28765
28783
|
if (!target) return;
|
|
28766
|
-
const probe2 = await
|
|
28767
|
-
host: "127.0.0.1",
|
|
28784
|
+
const probe2 = await probeCaddyTlsEitherFamily({
|
|
28768
28785
|
port: target.port,
|
|
28769
28786
|
servername: target.servername,
|
|
28770
28787
|
timeoutMs: cfg.probeTimeoutMs
|
|
@@ -28843,7 +28860,7 @@ function deriveReportedStatus(tracked, live, opts = {}) {
|
|
|
28843
28860
|
if (tracked === "running" && !live.caddyAlive) return "crashed";
|
|
28844
28861
|
return tracked;
|
|
28845
28862
|
}
|
|
28846
|
-
var define_process_env_default$
|
|
28863
|
+
var define_process_env_default$c = {};
|
|
28847
28864
|
const defaultElevate$1 = async ({ command, prompt, tmpFiles = [] }) => {
|
|
28848
28865
|
if (!process.send) {
|
|
28849
28866
|
return { success: false, error: "No host bridge available for elevation" };
|
|
@@ -28871,12 +28888,12 @@ async function getCaddyDataDir() {
|
|
|
28871
28888
|
userDataPath = path.join(os$1.homedir(), "Library", "Application Support", "Supbuddy");
|
|
28872
28889
|
} else if (platform === "win32") {
|
|
28873
28890
|
userDataPath = path.join(
|
|
28874
|
-
define_process_env_default$
|
|
28891
|
+
define_process_env_default$c.APPDATA || path.join(os$1.homedir(), "AppData", "Roaming"),
|
|
28875
28892
|
"Supbuddy"
|
|
28876
28893
|
);
|
|
28877
28894
|
} else {
|
|
28878
28895
|
userDataPath = path.join(
|
|
28879
|
-
define_process_env_default$
|
|
28896
|
+
define_process_env_default$c.XDG_CONFIG_HOME || path.join(os$1.homedir(), ".config"),
|
|
28880
28897
|
"Supbuddy"
|
|
28881
28898
|
);
|
|
28882
28899
|
}
|
|
@@ -29253,7 +29270,7 @@ const caddyCaManager = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defi
|
|
|
29253
29270
|
parseKeychainSha1Fingerprints,
|
|
29254
29271
|
uninstallCaddyCA
|
|
29255
29272
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
29256
|
-
var define_process_env_default$
|
|
29273
|
+
var define_process_env_default$b = {};
|
|
29257
29274
|
const execFileP$1 = util$1.promisify(child_process.execFile);
|
|
29258
29275
|
const PLIST_LABEL = "com.cueplusplus.supbuddy.bundled-runtime-ca-trust";
|
|
29259
29276
|
const MANAGED_ENV_VARS = ["NODE_EXTRA_CA_CERTS"];
|
|
@@ -29266,12 +29283,12 @@ function getSupbuddyDataDir() {
|
|
|
29266
29283
|
}
|
|
29267
29284
|
if (platform === "win32") {
|
|
29268
29285
|
return path.join(
|
|
29269
|
-
define_process_env_default$
|
|
29286
|
+
define_process_env_default$b.APPDATA || path.join(os$1.homedir(), "AppData", "Roaming"),
|
|
29270
29287
|
"Supbuddy"
|
|
29271
29288
|
);
|
|
29272
29289
|
}
|
|
29273
29290
|
return path.join(
|
|
29274
|
-
define_process_env_default$
|
|
29291
|
+
define_process_env_default$b.XDG_CONFIG_HOME || path.join(os$1.homedir(), ".config"),
|
|
29275
29292
|
"Supbuddy"
|
|
29276
29293
|
);
|
|
29277
29294
|
}
|
|
@@ -29310,7 +29327,7 @@ function planCaEnvSanitization(env) {
|
|
|
29310
29327
|
const setNodeExtra = ne == null || ne === "" || isSupbuddyOwnedCaPath(ne) ? getBundlePath() : null;
|
|
29311
29328
|
return { unset, setNodeExtra };
|
|
29312
29329
|
}
|
|
29313
|
-
function getSanitizedSpawnEnv(base = define_process_env_default$
|
|
29330
|
+
function getSanitizedSpawnEnv(base = define_process_env_default$b) {
|
|
29314
29331
|
const out = { ...base };
|
|
29315
29332
|
const plan2 = planCaEnvSanitization(out);
|
|
29316
29333
|
for (const v of plan2.unset) delete out[v];
|
|
@@ -29320,10 +29337,10 @@ function getSanitizedSpawnEnv(base = define_process_env_default$9) {
|
|
|
29320
29337
|
return out;
|
|
29321
29338
|
}
|
|
29322
29339
|
function sanitizeOwnProcessEnv() {
|
|
29323
|
-
const plan2 = planCaEnvSanitization(define_process_env_default$
|
|
29324
|
-
for (const v of plan2.unset) delete define_process_env_default$
|
|
29340
|
+
const plan2 = planCaEnvSanitization(define_process_env_default$b);
|
|
29341
|
+
for (const v of plan2.unset) delete define_process_env_default$b[v];
|
|
29325
29342
|
if (plan2.setNodeExtra && fsSync.existsSync(plan2.setNodeExtra)) {
|
|
29326
|
-
define_process_env_default$
|
|
29343
|
+
define_process_env_default$b.NODE_EXTRA_CA_CERTS = plan2.setNodeExtra;
|
|
29327
29344
|
}
|
|
29328
29345
|
if (plan2.unset.length) {
|
|
29329
29346
|
console.log("[brt] sanitized stale CA replace-vars from daemon env:", plan2.unset.join(", "));
|
|
@@ -29454,7 +29471,7 @@ async function scanProcessesForNodeExtra() {
|
|
|
29454
29471
|
const PROBEABLE_SHELLS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "ksh", "dash", "fish"]);
|
|
29455
29472
|
async function probeLoginShellNodeExtra() {
|
|
29456
29473
|
if (process.platform === "win32") return null;
|
|
29457
|
-
const shell = define_process_env_default$
|
|
29474
|
+
const shell = define_process_env_default$b.SHELL || "/bin/sh";
|
|
29458
29475
|
if (!PROBEABLE_SHELLS.has(path.basename(shell))) return null;
|
|
29459
29476
|
try {
|
|
29460
29477
|
const { stdout } = await execFileP$1(
|
|
@@ -29464,8 +29481,8 @@ async function probeLoginShellNodeExtra() {
|
|
|
29464
29481
|
// A clean slate — HOME/USER only, so the profile chain is the ONLY source.
|
|
29465
29482
|
env: {
|
|
29466
29483
|
HOME: os$1.homedir(),
|
|
29467
|
-
USER: define_process_env_default$
|
|
29468
|
-
LOGNAME: define_process_env_default$
|
|
29484
|
+
USER: define_process_env_default$b.USER ?? "",
|
|
29485
|
+
LOGNAME: define_process_env_default$b.LOGNAME ?? define_process_env_default$b.USER ?? "",
|
|
29469
29486
|
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
29470
29487
|
TERM: "dumb"
|
|
29471
29488
|
},
|
|
@@ -29505,7 +29522,7 @@ function getPlistPath() {
|
|
|
29505
29522
|
}
|
|
29506
29523
|
function getEnvironmentDPath() {
|
|
29507
29524
|
return path.join(
|
|
29508
|
-
define_process_env_default$
|
|
29525
|
+
define_process_env_default$b.XDG_CONFIG_HOME || path.join(os$1.homedir(), ".config"),
|
|
29509
29526
|
"environment.d",
|
|
29510
29527
|
"supbuddy-ca.conf"
|
|
29511
29528
|
);
|
|
@@ -29962,7 +29979,7 @@ async function getStatus(lastRefreshedAt, opts = {}) {
|
|
|
29962
29979
|
observations.push({ origin: "managed", value: env.NODE_EXTRA_CA_CERTS, detail: getMethod() });
|
|
29963
29980
|
}
|
|
29964
29981
|
if (effective.loginShell) {
|
|
29965
|
-
observations.push({ origin: "login-shell", value: effective.loginShell, detail: define_process_env_default$
|
|
29982
|
+
observations.push({ origin: "login-shell", value: effective.loginShell, detail: define_process_env_default$b.SHELL ?? "login shell" });
|
|
29966
29983
|
}
|
|
29967
29984
|
observations.push(...effective.processes);
|
|
29968
29985
|
const classified = classifyNodeExtraCaCerts(observations, bundlePath);
|
|
@@ -30180,7 +30197,7 @@ async function testTrust(opts) {
|
|
|
30180
30197
|
try {
|
|
30181
30198
|
const { stdout, stderr } = await execFileP$1(process.execPath, ["-e", script], {
|
|
30182
30199
|
env: {
|
|
30183
|
-
...define_process_env_default$
|
|
30200
|
+
...define_process_env_default$b,
|
|
30184
30201
|
NODE_EXTRA_CA_CERTS: bundlePath
|
|
30185
30202
|
},
|
|
30186
30203
|
encoding: "utf-8",
|
|
@@ -33484,7 +33501,7 @@ const {
|
|
|
33484
33501
|
safeDump
|
|
33485
33502
|
} = yaml;
|
|
33486
33503
|
const execAsync$b = util$1.promisify(child_process.exec);
|
|
33487
|
-
const execFileAsync$
|
|
33504
|
+
const execFileAsync$5 = util$1.promisify(child_process.execFile);
|
|
33488
33505
|
const BASE_COMPOSE_NAMES = [
|
|
33489
33506
|
"docker-compose.yml",
|
|
33490
33507
|
"docker-compose.yaml",
|
|
@@ -33608,7 +33625,7 @@ function dockerArgv(inner, dindContainerId) {
|
|
|
33608
33625
|
return dindContainerId ? ["exec", dindContainerId, "docker", ...inner] : inner;
|
|
33609
33626
|
}
|
|
33610
33627
|
async function runDocker(inner, dindContainerId) {
|
|
33611
|
-
const { stdout } = await execFileAsync$
|
|
33628
|
+
const { stdout } = await execFileAsync$5("docker", dockerArgv(inner, dindContainerId), {
|
|
33612
33629
|
timeout: 1e4,
|
|
33613
33630
|
maxBuffer: 16 * 1024 * 1024
|
|
33614
33631
|
});
|
|
@@ -33778,12 +33795,12 @@ const composeScanner = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defi
|
|
|
33778
33795
|
resolveComposeProject,
|
|
33779
33796
|
scanDockerCompose
|
|
33780
33797
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
33781
|
-
var define_process_env_default$
|
|
33798
|
+
var define_process_env_default$a = {};
|
|
33782
33799
|
const runningScripts = /* @__PURE__ */ new Map();
|
|
33783
33800
|
const MAX_LOG_LINES = 1e3;
|
|
33784
33801
|
function getAugmentedPath() {
|
|
33785
|
-
const currentPath = define_process_env_default$
|
|
33786
|
-
const homeDir = define_process_env_default$
|
|
33802
|
+
const currentPath = define_process_env_default$a.PATH || "";
|
|
33803
|
+
const homeDir = define_process_env_default$a.HOME || require("os").homedir() || "";
|
|
33787
33804
|
const extraPaths = [
|
|
33788
33805
|
"/opt/homebrew/bin",
|
|
33789
33806
|
"/opt/homebrew/sbin",
|
|
@@ -33852,7 +33869,7 @@ async function startScript(projectId, projectPath, scriptName, command, packageM
|
|
|
33852
33869
|
console.log(`[ScriptManager] Starting script: ${scriptName} (${packageManager} run ${scriptName}) in ${projectPath}`);
|
|
33853
33870
|
console.log(`[ScriptManager] PATH: ${augmentedPath}`);
|
|
33854
33871
|
try {
|
|
33855
|
-
child_process.execSync(`command -v ${packageManager}`, { env: getSanitizedSpawnEnv({ ...define_process_env_default$
|
|
33872
|
+
child_process.execSync(`command -v ${packageManager}`, { env: getSanitizedSpawnEnv({ ...define_process_env_default$a, PATH: augmentedPath }), stdio: "pipe" });
|
|
33856
33873
|
} catch {
|
|
33857
33874
|
const installHint = packageManager === "yarn" ? "Install it with: npm install -g yarn" : packageManager === "pnpm" ? "Install it with: npm install -g pnpm" : "Ensure Node.js is installed and npm is available in your PATH";
|
|
33858
33875
|
const errorMsg = `"${packageManager}" is not installed or not found in PATH. ${installHint}`;
|
|
@@ -33881,7 +33898,7 @@ async function startScript(projectId, projectPath, scriptName, command, packageM
|
|
|
33881
33898
|
shell: true,
|
|
33882
33899
|
// Sanitize so a stale inherited Supbuddy-owned SSL_CERT_FILE (replace-
|
|
33883
33900
|
// semantics, honored by Node 26) never reaches the user's dev server.
|
|
33884
|
-
env: getSanitizedSpawnEnv({ ...define_process_env_default$
|
|
33901
|
+
env: getSanitizedSpawnEnv({ ...define_process_env_default$a, PATH: augmentedPath, FORCE_COLOR: "1" })
|
|
33885
33902
|
});
|
|
33886
33903
|
} catch (err) {
|
|
33887
33904
|
console.error(`[ScriptManager] Failed to spawn: ${err.message}`);
|
|
@@ -43403,6 +43420,11 @@ const TOOL_SCOPE_MAP = {
|
|
|
43403
43420
|
cloud_teardown: "projects",
|
|
43404
43421
|
cloud_sign_in: "system",
|
|
43405
43422
|
get_cloud_status: "read",
|
|
43423
|
+
// Live sync. `start` takes the same scope as any per-project write because it IS one: the first pass
|
|
43424
|
+
// is one-way, so it can overwrite the local directory. `status` only reads.
|
|
43425
|
+
cloud_sync_start: "projects",
|
|
43426
|
+
cloud_sync_stop: "projects",
|
|
43427
|
+
cloud_sync_status: "read",
|
|
43406
43428
|
restore_project: "projects",
|
|
43407
43429
|
refresh_project_context: "read",
|
|
43408
43430
|
// services
|
|
@@ -43688,9 +43710,9 @@ class McpAuditLog extends require$$0$1.EventEmitter {
|
|
|
43688
43710
|
}
|
|
43689
43711
|
}
|
|
43690
43712
|
}
|
|
43691
|
-
var define_process_env_default$
|
|
43713
|
+
var define_process_env_default$9 = {};
|
|
43692
43714
|
function getAuditPath() {
|
|
43693
|
-
const dir = define_process_env_default$
|
|
43715
|
+
const dir = define_process_env_default$9.SUPBUDDY_STATE_DIR ?? path.join(os$1.homedir(), "Library", "Application Support", "Supbuddy");
|
|
43694
43716
|
return path.join(dir, "mcp-audit.jsonl");
|
|
43695
43717
|
}
|
|
43696
43718
|
const cap = () => useStore.getState().settings.mcp?.audit_cap ?? 5e3;
|
|
@@ -44551,7 +44573,7 @@ const projectTools = {
|
|
|
44551
44573
|
}
|
|
44552
44574
|
};
|
|
44553
44575
|
const execAsync$7 = util$1.promisify(child_process.exec);
|
|
44554
|
-
const execFileAsync$
|
|
44576
|
+
const execFileAsync$4 = util$1.promisify(child_process.execFile);
|
|
44555
44577
|
const SENSITIVE_PATTERNS = [/secret/i, /password/i, /token/i, /key/i, /credential/i];
|
|
44556
44578
|
const PUBLIC_PATTERN = /public/i;
|
|
44557
44579
|
function sanitizeEnv(content) {
|
|
@@ -44643,7 +44665,7 @@ async function exportBundle(project, outputPath, onProgress, slim = false) {
|
|
|
44643
44665
|
}
|
|
44644
44666
|
} else {
|
|
44645
44667
|
try {
|
|
44646
|
-
const { stdout } = await execFileAsync$
|
|
44668
|
+
const { stdout } = await execFileAsync$4(
|
|
44647
44669
|
composeCmd[0],
|
|
44648
44670
|
[...composeCmd.slice(1), "config", "--images"],
|
|
44649
44671
|
{ cwd: project.path }
|
|
@@ -44652,7 +44674,7 @@ async function exportBundle(project, outputPath, onProgress, slim = false) {
|
|
|
44652
44674
|
} catch {
|
|
44653
44675
|
}
|
|
44654
44676
|
try {
|
|
44655
|
-
const { stdout } = await execFileAsync$
|
|
44677
|
+
const { stdout } = await execFileAsync$4(
|
|
44656
44678
|
composeCmd[0],
|
|
44657
44679
|
[...composeCmd.slice(1), "config", "--volumes"],
|
|
44658
44680
|
{ cwd: project.path }
|
|
@@ -44835,7 +44857,7 @@ async function dumpInto(dir, container) {
|
|
|
44835
44857
|
const { size } = await fs.stat(file);
|
|
44836
44858
|
return { dir, file, bytes: size, sha256: hash.digest("hex") };
|
|
44837
44859
|
}
|
|
44838
|
-
var define_process_env_default$
|
|
44860
|
+
var define_process_env_default$8 = {};
|
|
44839
44861
|
const execFileP = util$1.promisify(child_process.execFile);
|
|
44840
44862
|
const REFRESH_BUFFER_MS = 60 * 1e3;
|
|
44841
44863
|
function toSession(t, emailFallback) {
|
|
@@ -44900,6 +44922,79 @@ async function cloudApiError(res, what) {
|
|
|
44900
44922
|
function bearerJson(session) {
|
|
44901
44923
|
return { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json" };
|
|
44902
44924
|
}
|
|
44925
|
+
async function fetchSyncClientKey(cfg, session) {
|
|
44926
|
+
const f2 = cfg.fetchImpl ?? fetch;
|
|
44927
|
+
const res = await f2(`${cfg.apiBase}/api/cloud/sync/client-key`, {
|
|
44928
|
+
method: "POST",
|
|
44929
|
+
headers: bearerJson(session)
|
|
44930
|
+
});
|
|
44931
|
+
if (res.status === 501) return null;
|
|
44932
|
+
if (!res.ok) throw await cloudApiError(res, "Fetch sync client key");
|
|
44933
|
+
const body = await res.json();
|
|
44934
|
+
if (!body.key || !body.tailnetDomain) {
|
|
44935
|
+
throw new Error("Fetch sync client key failed: the control plane returned no key or no tailnet domain");
|
|
44936
|
+
}
|
|
44937
|
+
return { key: body.key, tailnetDomain: body.tailnetDomain };
|
|
44938
|
+
}
|
|
44939
|
+
function syncDeps() {
|
|
44940
|
+
return {
|
|
44941
|
+
resolveProject: async (projectId) => {
|
|
44942
|
+
const project = useStore.getState().getProject(projectId);
|
|
44943
|
+
if (!project) throw new Error(`Project ${projectId} not found`);
|
|
44944
|
+
const link = project.cloud;
|
|
44945
|
+
if (!link) throw new Error(`Project ${projectId} is not linked to a cloud stack — push it first`);
|
|
44946
|
+
if (!project.path) throw new Error(`Project ${projectId} has no local path to sync`);
|
|
44947
|
+
return { stackId: link.stackId, localPath: project.path };
|
|
44948
|
+
},
|
|
44949
|
+
fetchCredentials: async (stackId) => {
|
|
44950
|
+
const { cfg, session } = await requireCloudSession();
|
|
44951
|
+
return fetchSyncCredentials(cfg, session, stackId);
|
|
44952
|
+
},
|
|
44953
|
+
fetchTailnetKey: async () => {
|
|
44954
|
+
const { cfg, session } = await requireCloudSession();
|
|
44955
|
+
return fetchSyncClientKey(cfg, session);
|
|
44956
|
+
}
|
|
44957
|
+
};
|
|
44958
|
+
}
|
|
44959
|
+
async function requireCloudSession() {
|
|
44960
|
+
const cfg = cloudConfigFromEnv();
|
|
44961
|
+
if (!cfg) throw new Error("Supbuddy Cloud is not configured");
|
|
44962
|
+
if (!currentSession) throw new Error("Not signed in to Supbuddy Cloud");
|
|
44963
|
+
currentSession = await ensureValidSession(cfg, currentSession);
|
|
44964
|
+
await saveCloudSession(currentSession);
|
|
44965
|
+
return { cfg, session: currentSession };
|
|
44966
|
+
}
|
|
44967
|
+
async function daemonSyncStart(projectId, authority) {
|
|
44968
|
+
const m = await __vitePreload(() => Promise.resolve().then(() => projectSync), false ? __VITE_PRELOAD__ : void 0);
|
|
44969
|
+
return m.syncStart(syncDeps(), { projectId, authority });
|
|
44970
|
+
}
|
|
44971
|
+
async function daemonSyncStatus(projectId) {
|
|
44972
|
+
const m = await __vitePreload(() => Promise.resolve().then(() => projectSync), false ? __VITE_PRELOAD__ : void 0);
|
|
44973
|
+
let stackStopped = false;
|
|
44974
|
+
try {
|
|
44975
|
+
const status = await daemonCloudProjectStatus(projectId);
|
|
44976
|
+
stackStopped = status.project?.stack?.status === "stopped";
|
|
44977
|
+
} catch {
|
|
44978
|
+
}
|
|
44979
|
+
return m.syncStatus(syncDeps(), projectId, { stackStopped });
|
|
44980
|
+
}
|
|
44981
|
+
async function daemonSyncStop(projectId) {
|
|
44982
|
+
const m = await __vitePreload(() => Promise.resolve().then(() => projectSync), false ? __VITE_PRELOAD__ : void 0);
|
|
44983
|
+
return m.syncStop(syncDeps(), projectId);
|
|
44984
|
+
}
|
|
44985
|
+
async function fetchSyncCredentials(cfg, session, stackId) {
|
|
44986
|
+
const f2 = cfg.fetchImpl ?? fetch;
|
|
44987
|
+
const res = await f2(`${cfg.apiBase}/api/cloud/stacks/${stackId}/sync-credentials`, {
|
|
44988
|
+
method: "POST",
|
|
44989
|
+
headers: bearerJson(session)
|
|
44990
|
+
});
|
|
44991
|
+
if (!res.ok) throw await cloudApiError(res, "Fetch sync credentials");
|
|
44992
|
+
const body = await res.json();
|
|
44993
|
+
if (!body.clientPrivateKey || !body.hostPublicKey || !body.tailnetHostname) {
|
|
44994
|
+
throw new Error("Fetch sync credentials failed: the control plane returned an incomplete credential");
|
|
44995
|
+
}
|
|
44996
|
+
return body;
|
|
44997
|
+
}
|
|
44903
44998
|
async function createCloudProject(cfg, session, input) {
|
|
44904
44999
|
const f2 = cfg.fetchImpl ?? fetch;
|
|
44905
45000
|
const res = await f2(`${cfg.apiBase}/api/cloud/projects`, {
|
|
@@ -45037,9 +45132,9 @@ async function cloudTeardownStack(cfg, session, stackId) {
|
|
|
45037
45132
|
}
|
|
45038
45133
|
let currentSession = null;
|
|
45039
45134
|
function cloudConfigFromEnv() {
|
|
45040
|
-
const supabaseUrl = define_process_env_default$
|
|
45041
|
-
const anonKey = define_process_env_default$
|
|
45042
|
-
const apiBase = define_process_env_default$
|
|
45135
|
+
const supabaseUrl = define_process_env_default$8.SUPBUDDY_CLOUD_SUPABASE_URL;
|
|
45136
|
+
const anonKey = define_process_env_default$8.SUPBUDDY_CLOUD_ANON_KEY;
|
|
45137
|
+
const apiBase = define_process_env_default$8.SUPBUDDY_CLOUD_API_BASE;
|
|
45043
45138
|
return supabaseUrl && anonKey && apiBase ? { supabaseUrl, anonKey, apiBase } : null;
|
|
45044
45139
|
}
|
|
45045
45140
|
function getCloudState() {
|
|
@@ -45178,6 +45273,12 @@ async function daemonCloudTeardownProject(projectId) {
|
|
|
45178
45273
|
const msg2 = e instanceof Error ? e.message : String(e);
|
|
45179
45274
|
if (!/already terminating|already terminated/i.test(msg2)) throw e;
|
|
45180
45275
|
}
|
|
45276
|
+
try {
|
|
45277
|
+
const m = await __vitePreload(() => Promise.resolve().then(() => projectSync), false ? __VITE_PRELOAD__ : void 0);
|
|
45278
|
+
await m.syncStop(syncDeps(), projectId);
|
|
45279
|
+
} catch (e) {
|
|
45280
|
+
console.warn(`[cloud] could not stop sync for ${projectId} during teardown:`, e instanceof Error ? e.message : e);
|
|
45281
|
+
}
|
|
45181
45282
|
useStore.getState().updateProject(projectId, { cloud: void 0 });
|
|
45182
45283
|
return { ok: true, stackId: link.stackId };
|
|
45183
45284
|
}
|
|
@@ -46230,9 +46331,9 @@ async function installClaudeCode(target, projectRoot, token) {
|
|
|
46230
46331
|
await writeAtomic(filePath, asJsonString(existing));
|
|
46231
46332
|
return { written: true, path: filePath, backup };
|
|
46232
46333
|
}
|
|
46233
|
-
var define_process_env_default$
|
|
46334
|
+
var define_process_env_default$7 = {};
|
|
46234
46335
|
async function installClaudeDesktop(token) {
|
|
46235
|
-
const filePath = process.platform === "darwin" ? path.join(os$1.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json") : process.platform === "win32" ? path.join(define_process_env_default$
|
|
46336
|
+
const filePath = process.platform === "darwin" ? path.join(os$1.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json") : process.platform === "win32" ? path.join(define_process_env_default$7.APPDATA ?? "", "Claude", "claude_desktop_config.json") : path.join(os$1.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
46236
46337
|
const existing = await readJsoncOrEmpty(filePath);
|
|
46237
46338
|
existing.mcpServers = existing.mcpServers ?? {};
|
|
46238
46339
|
existing.mcpServers.supbuddy = {
|
|
@@ -48460,7 +48561,7 @@ async function detectTargets(projectPath) {
|
|
|
48460
48561
|
jetbrains: await isDir(path.join(projectPath, ".idea"))
|
|
48461
48562
|
};
|
|
48462
48563
|
}
|
|
48463
|
-
const DOCS_MARKDOWN = "# Supbuddy docs\n\n> Run multiple Supabase projects at once on one Mac, each with its own custom local domain.\n\n## Getting started\n\nThere are two ways to run Supbuddy. Use the **macOS desktop app** (steps below), or the **command-line interface**, which runs on macOS and Linux. For the CLI, install it with `npx supbuddy@latest` and jump to [Command-line interface](#command-line-interface-cli). The app and the CLI share the same state, so you can use either or both.\n\n### 1. Install\n\nDownload the latest `.dmg` from the [download page](/api/download). Drag **Supbuddy.app** into `/Applications` and launch it. Supbuddy is signed and notarized; macOS will not show a Gatekeeper warning. Requires an Apple Silicon Mac (M1/M2/M3/M4, arm64). The desktop app is macOS-only in v2, but the headless CLI runs on Linux too. See [Command-line interface](#command-line-interface-cli).\n\n### 2. Trust the local Certificate Authority\n\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** — an empty proxy never generates it. With that in place, open the app and click **Install** (the first-launch prompt, or **Settings → 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 — 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 … -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 — 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 — on Firefox/Zen — 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 → General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings → Network** tab.\n\n### 3. Add your first project\n\nClick **Add project** in the Configure tab and pick a project root folder (the one with `package.json` and/or `supabase/config.toml`). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:\n\n- Supabase Kong → `api.<project>.test`\n- Supabase Studio → `studio.<project>.test`\n- Supabase Inbucket / Mailpit → `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) → `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings → General → Default TLD**.\n\n> **`.local` is fine again, from 3.5.18.** Earlier versions made every managed domain resolve slowly — a name resolved in milliseconds *once* and then stalled **five seconds per concurrent lookup**, so `curl`, a single `fetch` and `dig` all looked healthy while any page issuing several requests at once failed with what looked like a connect timeout on the proxy. The advice used to be to move off `.local`, on the grounds that macOS reserves it for multicast DNS (RFC 6762). That was only half right, and the half that mattered was ours: Supbuddy's DNS server answered only `A` for managed domains and forwarded the IPv6 (`AAAA`) lookup to the upstream resolver, which never answers for a local name — so no reply was sent at all and the client waited out its own timeout. A name on a *non-reserved* suffix stalled identically (5003 ms against `.local`'s 5002 ms), which is what proved the suffix was not the cause. Supbuddy now answers `AAAA` itself with `::1`; because that is a positive answer it also satisfies macOS's multicast rule, so `.local` resolves in single-digit milliseconds like any other suffix. (A client that prefers IPv6 is refused on `[::1]:443` and falls back to IPv4 in about 3 ms — there is deliberately no IPv6 redirect, because one was tried and it silently broke the backend HTTPS port.) **There is no need to rename your domains.** `doctor` still ships `dns-local-tld-mdns-stall` as a canary — if it fires on 3.5.18 or newer, check `supbuddy version` first, since an updated app can still be attached to an older daemon.\n\n### 4. Start the proxy\n\nToggle the project on. Supbuddy starts Caddy on port 8443 (HTTPS) and starts its built-in DNS server on port 5353. If you want real ports 80/443 instead of 8080/8443, enable **port forwarding** in **Settings → Network**. Supbuddy 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 — 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 → port pair (e.g. `api.acme.test → 54321`). Auto-generated mappings are tied to a detected service or app; you can also create manual ones.\n- **Isolation mode**: per-project. One of:\n - `thin` (lightweight, **the default for newly registered projects**): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own **port block** and a unique Compose `project_id`, written into that project's `supabase/config.toml`. That's what lets several Supabase projects run **at once on the shared daemon**, each reached by name (`api.<project>.test`, `studio.<project>.test`). Apps bind a **per-project loopback IP** (127.0.0.2, 127.0.0.3, …) so every project's dev servers keep their canonical ports — each project gets its *own* `:3000`. Start dev servers with `supbuddy run -- <dev command>` so they bind that IP. Supbuddy owns those config.toml keys while the project is `thin` and restores them the moment you switch back to `host`.\n - `host`: everything shares `127.0.0.1` and the stock ports. Dev-server ports collide across projects, and only one host-mode Supabase project can run at a time (the standard `supabase start` constraint). Use `host` **only when the project's Supabase stack is already running on the host independently of Supbuddy** (you run `supabase start` yourself and don't want Supbuddy re-porting `config.toml`). MCP registration (`register_project`) detects that case and keeps such projects on `host` automatically; in the app's Add-project dialog, pick **Host** in the Environment section yourself.\n- **Active vs inactive**: any project can be \"active\" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.\n\n## Project cards (Configure tab)\n\nEach registered project appears as a card in the Configure tab. Cards have a single-row header that's always visible and a tab-based body that expands on click.\n\n### Header\n\nReading left to right:\n\n- **Expand chevron** + **project name**: click to expand/collapse the card.\n- **Status indicator**: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem's state.\n- **Tech badges**: e.g. `TurboRepo`, `Supabase` (shown when detected).\n\n**Supabase connection warning.** When a project's app `.env` is missing the\nSupabase connection vars, or they've gone stale relative to the live target\n(e.g. after switching isolation, which republishes ports), the card shows a\n`supabase env: not connected` / `supabase env: out of date` pill. Click it to\nopen Connect and push fresh values, or choose **Ignore for this project**.\n- **Env mode chip**: read-only `Host` or `Thin` label (matching the project's isolation mode). To switch modes, open the **Supabase** tab and use the **Environment** section at the top.\n- **Issues counter**: red for errors, amber for warnings. Click to open the **issues popover** (see below). Hidden when there are no issues.\n- **Warnings chip**: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.\n- **Enable toggle** (right edge): turn the project's proxy on/off without deleting it.\n- **⋯ actions menu** (right edge): every project-level action: **Edit project**, **Rescan**, **Re-check configs** (re-runs the connection/env drift check for this project), **Select folder**, **Export bundle**, and **Delete project**.\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings. Each issue shows a severity icon, title, optional detail, and a **→ open {tab}** link. Clicking the link jumps to the relevant tab and closes the popover.\n\n### Body tabs (when expanded)\n\nThe body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header's **⋯ menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain → :port` (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed **edit** / **delete** / **access** (LAN / Tailscale state) actions and the per-mapping **toggle** to the right edge. A **Map** CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.\n\n#### Supabase (shown when Supabase is detected)\n\n**Environment section (top):** host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see [Migrating a legacy Isolated (VM) project to Thin](#migrating-a-legacy-isolated-vm-project-to-thin)).\n\n**Action bar:** Start, Stop, Restart buttons; a first-class **Connect** button (cyan, opens the connection panel for `.env` generation / merge); and a **More** menu with **Config editor** and **Details**.\n\n**Config editor: secret extraction.** When you save a `supabase/config.toml` that contains a secret-bearing value inline (e.g. an SMTP password under `[auth.email.smtp]`, an OAuth `secret`, or any `*_key`/`auth_token`), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root `.env.local`). The value is written there and replaced in `config.toml` with an `env(SUPABASE_…)` reference, so secrets never land in git. Supbuddy injects those `SUPABASE_`-prefixed values back into the `supabase start` environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)\n\n**Service rows** (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.\n\n#### Compose (shown when Compose services are detected)\n\n**Action bar:** Start, Stop, Restart. **Service rows** are read-only (status dot, name, URL). Add-on services declared in `supbuddy.addons.yml` (see **Add-on Compose services**) appear here alongside the base stack and in `get_compose_status` over MCP.\n\n#### Other (shown when non-Supabase, non-Compose services are detected)\n\nRead-only service rows: status dot, name, URL.\n\n#### Scripts (shown when scripts are detected)\n\nBookmarked scripts appear in a **Quick Access** group at the top; remaining scripts appear under **Other Scripts**. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.\n\n#### AI Tools\n\nWraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target **scope** (global / local), advanced options, and recent activity. See [Per-project AI context sync](#per-project-ai-context-sync) for what global vs. local means.\n\n> Project-level actions (**Edit**, **Rescan**, **Re-check configs**, **Select folder**, **Export bundle**, **Delete**) are no longer a tab. They live in the header's **⋯ actions menu**.\n\n---\n\n## Multiple Supabase projects (the main use case)\n\nThe reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 Inbucket). Two projects on the same machine collide; you must `supabase stop` one before `supabase start`-ing the other.\n\nTwo ways to break that constraint, picked per project in the **Supabase** tab → **Environment** section:\n\n### Thin (lightweight, recommended)\n\nSwitch a project to **Thin**. Supbuddy assigns it a free port block (in the `55000+` range), writes those ports plus a unique Compose `project_id` into its `supabase/config.toml`, and runs `supabase start` on your **normal host Docker**, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (`api.acme.test`, `studio.acme.test`, `mail.acme.test`). Switch back to **Host** and Supbuddy restores the original `config.toml` and stops just that project's stack.\n\nThis is the lightest, fastest option and the right default for most setups — which is why **newly registered projects default to Thin**. One caveat: if your `config.toml` omits a port key (e.g. `[inbucket] smtp_port`), Supbuddy can't relocate a port that isn't declared, so that one service falls back to its stock port. That is fine for a single project, but spell those keys out if two Thin projects need the same service.\n\n### Dev servers on Thin: every project keeps its own `:3000`\n\nA Thin project also gets its own **loopback IP** (127.0.0.2, 127.0.0.3, …, persisted per project). Its app dev servers bind that IP instead of `127.0.0.1`, so canonical ports never collide across projects — five Next.js apps in five projects can all run on `:3000` at once, and Supbuddy's proxy routes each `web.<project>.test` to its project's IP.\n\nStart dev servers through the launcher:\n\n```bash\nsupbuddy run -- next dev # binds -H <project loopback IP>, stays on :3000\nsupbuddy run -- vite # injects --host <ip> --strictPort\nsupbuddy run --print -- next dev # show what would run, without running it\n```\n\n`supbuddy run` reads the project's IP from the nearest `.supbuddy/meta.json` (`loopbackIp`, written when Thin is enabled), ensures the loopback alias exists, injects the right bind flag for the detected framework, and execs your command. It prints one concise line with the project's Caddy-proxied URL (e.g. `[supbuddy] → https://web.<project>.test`) — the address you should actually open. For **Next and Vite** it also hides the dev server's own `- Local:/- Network:` banner (which only echoes the raw loopback IP `127.0.0.N:<port>`, bypassing Supbuddy's HTTPS proxy): those two lines are filtered out of the piped output, every other line passes through untouched, and colours are preserved via `FORCE_COLOR` (stdin stays interactive). Other frameworks pass through with no filtering. When a project has several app mappings, it matches the one whose port equals the dev server's port (from `--port`/`-p` or the framework default), else lists them all. Make it the project's `dev` script (`\"dev\": \"supbuddy run -- next dev\"`) so nobody — humans or agents — has to remember it. **Never move an app to a nonstandard port because `127.0.0.1:3000` is busy**; that port belongs to another project's IP space.\n\n### When to stay on Host\n\nKeep a project on **Host** only when its Supabase stack runs on the host *independently of Supbuddy* — you run `supabase start` yourself on the stock ports and don't want Supbuddy rewriting `config.toml`. MCP registration (`register_project`) detects a stack like that (running containers for the project's `config.toml` `project_id`) and keeps the project on Host automatically; in the app's Add-project dialog, pick **Host** in the Environment section for such projects. Stop the stack (`supabase stop`) and switch to Thin whenever you're ready.\n\n### Running them all at once\n\nRegister as many projects as you want, and all of them can be \"active\" (proxied) at the same time. There's no limit. A Thin project's stack restarts in seconds; a Host project needs the standard `supabase start` cycle.\n\n### Migrating a legacy Isolated (VM) project to Thin\n\nIf you created a project in an older version of Supbuddy that used the now-retired **Isolated (VM)** mode, Supbuddy detects it on launch and offers a one-way, guided migration to **Thin**. The migration wizard appears in the **Supabase** tab's Environment section for any project still flagged as VM.\n\nThe migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there's no way to switch back (but your data is intact in the Thin stack).\n\nOver MCP, three tools handle the migration bridge:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode awaiting migration.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration (dump, restore, verify).\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.\n\n## Custom domains & TLDs\n\nEvery mapping resolves through Supbuddy's built-in DNS server on port 5353. By default the TLD is `.test` (an IETF-reserved TLD safe for local use). You can change the default in **Settings → General → Default TLD** to `local`, `dev`, or anything else; existing mappings are migrated to the new TLD on save.\n\nFor host resolution, Supbuddy *does not* use `/etc/hosts` for wildcards; it runs a DNS resolver. macOS's default resolver only queries port 53; Supbuddy installs a per-project resolver file under `/etc/resolver/<project-domain>` (e.g. `/etc/resolver/myapp.local`) pointing at `127.0.0.1:5353`. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like `.local` (which Bonjour/mDNS owns). You'll be prompted for sudo the first time this changes.\n\nResolver files exist only for domains the proxy actually serves — 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 → Default TLD, e.g. `.test`). A single project can opt into its **own** TLD — 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 → Network), Supbuddy binds Caddy to `0.0.0.0` instead of `127.0.0.1` and runs an mDNS responder so other machines on your local network can reach your dev servers via `<hostname>.local`. Useful for testing on your phone or another laptop without setting up Tailscale.\n\n**`.local` TLD + LAN sharing:** macOS reserves the `.local` namespace for Bonjour/mDNS (RFC 6762), and macOS's TCP stack short-circuits self-connections to your own LAN IP via the loopback path *without consulting `pf`*, so the obvious \"redirect lo0 → my LAN IP\" trick can't fix it. Supbuddy's mDNS responder works around this by **ignoring queries that originate from this machine**, letting the OS resolver fall through to `/etc/resolver/<project-domain>` (which routes to `127.0.0.1` where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: `.local` works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to `.test`, you can switch back.\n\nIf `studio.<project>.local` (or similar) doesn't load: open the Configure tab. A red banner will tell you whether it's a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.\n\n### Tailscale\n\nIf you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.\n\n## Monorepo support\n\nSupbuddy auto-detects these monorepo layouts when scanning a project root:\n\n- Turborepo (presence of `turbo.json`)\n- pnpm workspaces (`pnpm-workspace.yaml`)\n- npm/yarn workspaces (`workspaces` field in root `package.json`)\n- Common folder layouts: `apps/*`, `packages/*`, `services/*`, `sites/*`\n\nEach detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: `apps/*`, `packages/*`, `services/*`, `sites/*`, `db/`, `db/*`, `database/`, `database/*`, `packages/backend`, `packages/db`, `packages/database`.\n\n### Detected app frameworks\n\nPort detection looks for the framework dependency in `package.json` and combines that with: explicit `-p`/`--port` in the dev script, `PORT=` env in the dev script, or a config file read. If none of those resolve, the framework default is used:\n\n| Framework dependency | Default port |\n| --- | --- |\n| `next` | 3000 |\n| `vite` | 5173 |\n| `@remix-run/dev`, `@remix-run/serve` | 3000 |\n| `astro` | 4321 |\n| `nuxt`, `nuxt3` | 3000 |\n| `@sveltejs/kit` | 5173 |\n| `@angular/core` | 4200 |\n| `@nestjs/core` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | none (must be explicit in dev script) |\n\n### Server Actions allowedOrigins audit\n\nFor Next.js apps, Supbuddy reads your `next.config.{ts,mts,js,mjs,cjs}` and extracts the hosts in `experimental.serverActions.allowedOrigins`. If a mapped subdomain is missing from that list, the project's **warnings chip** flags `next.config: N origins missing`; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the **Apps** tab (the chip's \"open apps\" jump) where the affected app shows the warning with a **Fix** button.\n\nThe Fix button opens a dialog with a paste-ready snippet and an **Apply…** button: click it to see a unified diff of the change Supbuddy will make to your `next.config`, then **Confirm & write** to apply it. Supbuddy handles the four common config shapes (existing `allowedOrigins` array, existing `serverActions` block without it, existing `experimental` block without `serverActions`, or no `experimental` at all). 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 — an identifier, a function call, a ternary, `[...] as string[]` — 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 — 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) — 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 (\"…\") is not allowed.` (403). A `.your-project.local` entry counts as covering every subdomain, so an existing wildcard suffix doesn't trigger a false warning.\n\nLike the Next.js audit, the affected app's **Fix** button on the **Apps** tab opens a dialog with a paste-ready snippet and an **Apply…** button that previews a unified diff and writes `server.allowedHosts` into your `vite.config` (handling an existing `allowedHosts` array, an existing `server` block without it, or no `server` block at all; `allowedHosts: true` is left untouched). The edit is strictly additive — existing entries, spreads and comments are kept verbatim and only missing hosts are appended — 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 → MCP → Add client**, pick the client kind, and Supbuddy generates a token, edits the client's config file, and backs up the original (`<file>.supbuddy-backup` next to it). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (**Settings → MCP → 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` — 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. `get_cloud_status` also returns a `box` summary — what the stack's box last reported doing, as a phase plus a per-unit state list, with `report_at` so the caller can age it. It is deliberately structural: the box's free-text detail is NOT included, because that text is written by whatever runs inside the box and this value reaches an agent's context. Absent (`null`) when the stack has never reported or runs an image with no reporter.\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`). Two port-forwarding fields mean different things and are reported separately: `enabled` is what you asked for, `enforced` is whether the `443 → 8443` redirect is actually live — probed, not remembered. `get_proxy_status` and `get_health` both carry the same distinction as `portForwardingEnabled` and `portForwardingEnforced`, and report `networkingDegraded: true` when the two disagree, because a redirect that is switched on and not working is an outage rather than a setting. The live probe is decisive in both directions: it overrides a stored flag that claims health, and it also clears one left behind by an abandoned repair once the redirect is confirmed working. `reload_port_forwarding` re-applies the rules with a sudo prompt and returns `ok` only once a fresh probe confirms 443 answers — a successful `pfctl` and a working redirect are not the same claim. `set_port_forwarding` deliberately returns **no `ok` field at all**: the elevation runs on the host and resolves after the tool has already replied, so it reports `requested` plus `confirmed: false` and points you at `get_port_forwarding_status`. It can still fail afterwards — a declined prompt, a timeout, or a ruleset that fails validation — and a success token there would be a guess, not an observation.\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`) — 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 — even for `auto_apply` clients — 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 — 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 → MCP → Clients** lists each client's granted scopes inline and exposes a **Scopes** button that opens the same scope editor used when adding a client.\n\n### Registering a project via MCP\n\n`register_project` takes a `root_path` (required), an optional `label`, `auto_scan` (default `true`), and an optional `isolation` (`'thin'` or `'host'`). It registers the project the same way the GUI's \"Add project\" flow does:\n\n- Derives a base domain as `<slug>.<defaultTld>` from the label (or the folder name), e.g. `staffhub.test`.\n- Records both the project `path` and `rootPath` so the project is visible to the proxy, scans, and file tools alike.\n- Scans the folder (unless `auto_scan: false`) for apps, services, scripts, and package manager.\n- Creates per-app subdomain mappings from the discovered apps (e.g. `site.staffhub.test → :3400`), derives the host service subdomains (`api.`, `studio.`, …), and reloads Caddy.\n- **Defaults to `thin` isolation**: the project gets its own loopback IP so its dev servers keep canonical ports (`:3000`) with no cross-project collisions — run them with `supbuddy run -- <dev command>`. The one exception: if the project's Supabase stack is **already running on the host outside Supbuddy**, registration keeps it on `host` (switching would rewrite its `config.toml` ports and orphan the running stack). Pass `isolation: 'host'` to opt out explicitly, or `isolation: 'thin'` to skip the detection and force thin.\n\nThe response includes an `isolation_note` explaining which mode was chosen and why — agents should read it instead of assuming.\n\n### Switching isolation over MCP\n\n`switch_isolation` ( `{ project_id, target_mode: 'host' | 'thin', auto_start? }` ) moves an existing project between **host** and **thin** mode. To-thin writes the per-project port block and `project_id` into `supabase/config.toml` and (unless `auto_start: false`) starts Supabase; to-host restores the original `config.toml` and stops that project's stack. It runs in the background and returns `{ started: true }`; poll `get_project` (`isolation`) for the current mode.\n\nA project can also be patched with `update_project`: its `patch` accepts `name`, `enabled`, `domain`, and `isolation` (it intentionally does **not** accept `path`/`rootPath`). Note that patching `isolation` only flips the flag; use `switch_isolation` to actually provision/tear down the port assignment.\n\n### Legacy VM migration over MCP\n\nFor projects still on the retired Isolated (VM) mode, three tools handle the one-way migration to Thin:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode, with their current `vmState` and migration readiness.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration. It dumps Postgres data from the VM, starts a fresh Thin stack, restores the dump, and row-count-verifies before signalling completion. Returns `{ started: true }`; poll `get_project` (`migrationState`) for progress.\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after verification passes. Errors if called before the verify step completes.\n\n### Repointing a project's Supabase config\n\n`set_supabase_config_path` ( `{ project_id, supabase_path }` ) switches which `supabase/config.toml` a project uses, for monorepos that carry more than one (e.g. a repo-root config and an app-level one). `supabase_path` is the project-relative directory **containing** the `supabase/` folder (`\".\"` for the repo root, e.g. `\"apps/getnightowls\"`). It persists the path, re-derives `supabaseProjectId` from the new config, and re-scans services. The previous stack's Docker volume is **left intact** (not deleted), so the switch is reversible; the response reports it under `orphaned_previous_stack`.\n\n### Moving a secret between env files\n\n`copy_env_var` ( `{ source_path, source_key, target_path, target_key? }` ) relocates a single variable from one env file to another (e.g. a value put in an app's `.env.local` that the stack actually injects from the repo-root `.env.local`). The value is read and written entirely inside the worker (it **never crosses the MCP boundary** and never appears in the audit log), so an agent can move a secret without it being printed. `target_key` defaults to `source_key`.\n\n### Plan / apply for destructive tools\n\nTools that delete or mutate state (`delete_mapping`, `delete_project`, `write_env_file`, etc.) return a *plan* with a preview. The MCP client (or you, in the Activity panel) explicitly calls `apply` with the `plan_id` to execute. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days.\n\n## Add-on Compose services\n\nA project can declare **extra** Docker Compose services that Supbuddy discovers, merges, runs, health-checks, and tails alongside the managed stack: a Redis cache, a worker queue, a search engine, etc. Add-on services run on the host's shared Docker daemon in both `host` and `thin` isolation, with no extra setup needed.\n\n### Declaration files & merge precedence\n\nSupbuddy looks for up to three Compose fragments in the project and merges them, later wins:\n\n1. `docker-compose.yml`: your base Compose file.\n2. `docker-compose.override.yml`: your own override, honored if present (standard Compose convention).\n3. `supbuddy.addons.yml`: Supbuddy-owned add-on fragment.\n\nAll present fragments are passed explicitly, e.g. `docker compose -f docker-compose.yml -f docker-compose.override.yml -f supbuddy.addons.yml --project-name <pinned> …`. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project's default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.\n\n### `supbuddy.addons.yml` format\n\nA valid Compose fragment (a standard `services:` map) plus an optional Supbuddy-only `x-supbuddy:` extension block. A plain `docker compose up` ignores `x-supbuddy:`, so the file stays usable without Supbuddy. Today `x-supbuddy` supports a one-shot **seed** step:\n\n```yaml\nservices:\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\nx-supbuddy:\n seed:\n service: redis\n command: [\"redis-cli\", \"ping\"] # explicit argv, runs once after services are healthy\n runOnce: true\n```\n\nThe seed step runs **once** after the add-on services are up and healthy. It's idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the `seed_addons` MCP tool.\n\n### MCP tools\n\n- `scaffold_addons` ( `{ project_id }` ): scope `config`. Creates a starter `supbuddy.addons.yml` if the project doesn't have one. Never clobbers an existing file.\n- `seed_addons` ( `{ project_id, force? }` ): scope `services`. Runs the declared `x-supbuddy.seed` step. Idempotent unless `force: true`.\n- `tail_service_logs` ( `{ project_id, service }` ): scope `log_tail`. Streams a Compose/add-on service's container logs over SSE (like `tail_request_logs`, but for container stdout/stderr).\n- `watch_supabase` ( `{ project_id }` ): scope `log_tail`. Streams a project's live Supabase start/stop/restart progress over SSE: `operation` (status + message), `progress` (image-pull/service snapshots), and `log` (raw lines, VM projects). The stream ends on a terminal status. Backs `supbuddy supabase start --follow`.\n\n### Scripts MCP tools\n\nScripts detected in a project (e.g. `dev`, `build`, `test`) are controllable over MCP:\n\n- `list_scripts` ( `{ project_id }` ): scope `read`. Returns all detected scripts with their current status and bookmark state.\n- `start_script` ( `{ project_id, script }` ): scope `services`. Starts the named script process.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process.\n- `bookmark_script` ( `{ project_id, script, bookmarked }` ): scope `services`. Pins (`bookmarked: true`) or unpins a script in the Quick Access group.\n- `tail_script_logs` ( `{ project_id, script }` ): scope `log_tail`. Streams the named script's stdout/stderr over SSE.\n\n### `get_compose_status` shape\n\n`get_compose_status` ( `{ project_id }` ) returns per-service status, not just whether Compose is installed:\n\n```json\n{\n \"project_id\": \"…\",\n \"compose_installed\": true,\n \"running\": true,\n \"services\": [\n { \"name\": \"redis\", \"status\": \"running\", \"health\": \"healthy\", \"ports\": [\"6379:6379\"], \"image\": \"redis:7-alpine\", \"container_id\": \"…\", \"source\": \"addons\" }\n ],\n \"services_source\": \"store-snapshot (updated by docker events, not probed by this call)\"\n}\n```\n\nEach service's `source` is one of `base` | `override` | `addons`, telling you which fragment declared it.\n\nThe service statuses are a **snapshot**, kept current by Supbuddy's docker-events watcher rather than probed when you call — which is why `services_source` says so. Only `compose_installed` is checked on the call itself. `get_supabase_status` reports the same way, and answers the question its name asks: `running` plus the project's Supabase services, alongside the machine-level `cli_installed` and `docker_running`.\n\n## Per-project AI context sync\n\nEach project has a **Context sync: AI tools** panel, accessible via the **AI Tools** tab in the project card, that writes a project-scoped briefing to disk so AI agents working in that repo see your live mappings, services, and isolation state without having to ask. Files written:\n\n- `.supbuddy/`: `README.md`, `mappings.md`, `services.md`, `project.md`, `mcp.md`, `do-not.md`, `docs.md`. The full live snapshot, regenerated on each sync.\n- `AGENTS.md` and `CLAUDE.md`: a small managed block prepended (or updated in place) telling the agent which project this is and pointing it at `.supbuddy/`.\n- Editor skill files when detected: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.github/copilot-instructions.md`, `.idea/supbuddy.md`.\n- `.gitignore` managed block, ignoring: `.supbuddy/meta.json` (volatile sync state), `*.supbuddy-backup-*` (rollback snapshots), and the per-editor skill files that are written **locally** (see scope below). The rest of `.supbuddy/` is intended to be committed; `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are also kept committable since you may have hand-written content there alongside Supbuddy's managed block.\n\n### Global vs. local scope\n\nThe per-editor skill files are generic Supbuddy-owned pointers (\"this is a Supbuddy project: read `.supbuddy/`, prefer the MCP tools\"). For editors that expose a **Supbuddy-owned global location**, Supbuddy writes that pointer **once, machine-wide** instead of copying it into every project, so it isn't duplicated across all your repos. Project-specific data always stays local in `.supbuddy/`.\n\n- **Claude Code** → one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** → `~/.cursor/skills/supbuddy/SKILL.md`. The global skill self-scopes: it only acts when the working directory has a `.supbuddy/` folder, and resolves the active project from that folder's `meta.json`.\n- All other targets (`windsurf`, `continue`, the `AGENTS.md`/`CLAUDE.md`/Copilot managed blocks, JetBrains) stay **local**: their \"global\" files are shared user files, so Supbuddy won't overwrite them.\n- Each target has a **scope** setting: `auto` (default: global for the Claude/Cursor skills, local for everything else), `global`, `local` (force per-project, useful if you commit the file for teammates), or `off`. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does **not** auto-remove these global files; delete them manually from `~/.claude/skills/supbuddy/` and `~/.cursor/skills/supbuddy/` if needed.\n- The always-loaded `CLAUDE.md`/`AGENTS.md` managed block stays local as a safety net so agents stay aware even if the on-demand global skill doesn't auto-activate.\n\nSync modes per project:\n\n- **Auto**: Supbuddy regenerates the files whenever mappings, services, or project state change.\n- **Manual only**: files are only written when you click **Sync now** (or use the tray's *Sync AI context for all projects*).\n- **Off**: nothing is written.\n\nThe collapsed header shows an at-a-glance status pill: mode (`auto` / `manual` / `off`), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn't present) appear greyed out in the **Detected targets** list inside the panel.\n\n## Supbuddy Cloud\n\nPush a project — its Supabase schema **and data** — to a hosted cloud dev-stack (its own full self-hosted Supabase — Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway — 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** — 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 → Cloud**.\n- **Push a project** — after signing in, each project's ⋯ menu gains **Push to cloud…**. 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** — a **☁** badge appears on its row; click it (or ⋯ → **Open in cloud**) to open the stack in the web app.\n- **CLI / MCP** — 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 ☁, and `get_project` / `list_projects` carry the `cloud` link. `cloud_teardown` (and the ⋯ teardown) destroy the remote stack and unlink it locally — routed through the same plan/apply gate as other destructive tools.\n- **Service breadth** — 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** — 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** — [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) — 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, plus which worker the daemon is running\nsupbuddy version # which CLI build this is, and which daemon it is talking to\nsupbuddy stop # graceful shutdown\n```\n\n`supbuddy version` answers a question that used to have no answer: **which copy of the CLI is this?** Three builds exist and they look identical — the one inside the desktop app (`host`), the one from npm (`npm`), and one built from a checkout (`dev`). The build kind is stamped in at compile time, because nothing at runtime can tell them apart: the version numbers match, and a working-tree build even carries the same `daemon/worker.cjs` layout as an npm install. It prints the CLI's version, build kind and path, plus the daemon's, and warns when the two disagree — a `dev` CLI driving a shipped daemon means unreleased code is running privileged repairs against your real machine.\n\nThe names `supbuddy` and `sup` are reserved for shipped builds. A `dev` build invoked under either name **refuses to run** and explains how to find the shadowing symlink, because `pnpm link` or a hand-made symlink in a directory that precedes `/usr/local/bin` on `PATH` otherwise silently replaces the installed CLI. To run a checkout, use `./scripts/supbuddy-dev <command>` — it runs from source and needs no build. It deliberately shares the production state dir: a daemon's machine-level resources (the worker port, the Caddyfile, `/etc/hosts`, `/etc/resolver`, the pf anchor, the launchd label) are **not** state-dir scoped, so pointing a dev daemon at a private state dir does not isolate it — it only hides the running daemon from the single-daemon check, after which the dev worker takes port 48760 by killing the process holding it. Sharing the state dir keeps that check working, so `supbuddy-dev daemon` declines while the app's daemon is running. A dev CLI driving a shipped daemon prints a warning on every command.\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**After the app updates itself, it replaces an outdated daemon.** The daemon is detached, so it survives the app relaunching — without this the app would look updated while still running the previous version's worker, and any fix shipped in that worker would silently not take effect. On launch the app compares the running daemon's version (stamped into `daemon.json`) against its own: an **older** daemon is stopped and replaced, and a **newer** one is left alone and attached to, since an out-of-date app must not downgrade a running worker. If the daemon ignores the graceful stop, the app **forces it** rather than carrying on as though the stop had worked — attaching to the daemon it just judged stale is exactly how an updated app ends up running old code, and the replacement spawn would be refused anyway (\"already running\"). Shutdown is bounded from the other side too: every stop step has a timeout and the worker exits even when a service refuses to stop, because a daemon that cannot be stopped cannot be updated. A forced shutdown may leave Caddy briefly running; the health monitor reaps it and the replacement daemon takes over.\n\n### Run on login (service)\n\n```bash\nsupbuddy service install # start-on-login (launchd on macOS, systemd-user on Linux)\nsupbuddy service status\nsupbuddy service uninstall\n```\n\n### Commands\n\nAll app surfaces have a command. Names follow `supbuddy <module> <action> [args] [--flags]`. The main groups:\n\n| Group | Examples |\n| --- | --- |\n| Dev launcher | `run [--print] -- <dev command>` — on a Thin project, binds the dev server to the project's loopback IP (from `.supbuddy/meta.json`) so it keeps its canonical port (e.g. `supbuddy run -- next dev` stays on `:3000`) |\n| Health / proxy | `status`, `doctor [--fix]` (health & drift scan — see *System doctor*), `reset [--tier=soft\\|deep\\|full]` (tiered system reset — 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>` — push a project (with its Supabase data) to a hosted cloud stack; `project ls` marks pushed projects with ☁ |\n| Compose | `compose up\\|down\\|restart\\|status\\|logs <proj> [svcs]` |\n| Scripts | `scripts ls\\|start\\|stop\\|restart\\|logs\\|bookmark <proj> [script]` |\n| Isolation | `isolation switch <proj> <host\\|thin>`, `isolation pending-migrations`, `migrate start\\|finish <uuid>` |\n| Certificates | `ca status\\|install\\|uninstall` |\n| Env files | `env copy <src> <key> <target>`, `env write <path> <K=V>…` |\n| Settings | `settings get`, `settings set --json <patch>` |\n| MCP | `mcp add [<agent>]` (register Supbuddy into a coding agent: interactive, or `--write`/`--print`/`--prompt`), `mcp ls`, `mcp revoke <id>`, `mcp approvals apply\\|cancel <id>` |\n| Host / network | `connect`, `trust`, `tailscale`, `dns`, `pf` (port-forwarding) |\n| Logs | `logs requests [-f]`, `logs audit [-f]`, `logs get <id>` |\n| Account | `account`, `caps`, `addons scaffold\\|seed <proj>` |\n| Dashboard | `tui` (alias `dash`) |\n\nGlobal flags: `--json` (machine-readable output), `--yes` (skip confirmations), `--quiet`, `--url`/`--token` (attach to a specific/remote daemon instead of auto-discovery), `--state-dir` (override the shared dir), `--timeout`, and `-f`/`--follow` for streaming log commands and live `supabase start|stop|restart` progress.\n\nDestructive operations go through the same **plan → apply** gate as MCP (see *Plan / apply for destructive tools*); the CLI's control token is granted auto-apply, so they execute directly.\n\n### Live dashboard (TUI)\n\n```bash\nsupbuddy tui # or: sup dash\n```\n\n`supbuddy tui` opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project's isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press `r` to refresh, `q` to quit. It needs a running daemon (`supbuddy daemon --detach`); if none is found it tells you so.\n\n### 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 — **critical**, **warning**, **info** — each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing, so you can gate a script or CI on it.\n\n**Exit codes.** A check that can't run is an *unknown*, not a clean bill of health — so the scan reports \"I couldn't look\" separately from \"I looked and it's fine\":\n\n| Code | Meaning |\n|---|---|\n| `0` | The scan completed and found nothing critical |\n| `1` | **Critical** findings — something is definitely broken |\n| `2` | The scan **could not complete** — one or more checks never ran (see **SCAN ERRORS** in the output), so the result is an unknown |\n\nExit `2` covers cases that used to (wrongly) exit `0`: with Docker stopped, for example, every Docker-backed check fails to run, and a `0` there would tell CI the machine was healthy while part of the scan was blind. A critical finding outranks an incomplete scan — if both apply you get `1`, because that's the actionable one. Gating on \"non-zero\" catches both; check for `2` specifically if you want to start Docker and retry rather than fail the build. These codes apply to `--fix` too: a run where every repair applied but part of the scan never ran also exits `2`.\n\n`--fix` re-scans, prints a **manifest** — one line per fixable finding, taken from the scan you just saw — 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\n**An aborted `--fix` also exits non-zero (`1`).** Declining the confirmation applies nothing, so every finding is still there — exiting `0` would tell a script the machine was fine when it had just been reported as critical. This matters most where nobody actually declined: with no TTY to prompt on, `--fix` refuses on principle (confirm-before-harm), so a scripted run prints `aborted — no fixes applied` and stops. Pass `--yes` to run it unattended. A daemon-side denial of the confirmation has always exited `1`; the same outcome now gets the same code regardless of which side refused.\n\nThe doctor ships **21 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 — 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| `dns-not-resolving` | critical | Supbuddy serves these domains but the OS will not resolve them, so every mapped URL fails before it reaches the proxy — a **missing** `/etc/resolver` file, the local DNS server **not answering**, or (the case a file audit calls healthy) the files being correct while the OS has never **loaded** them. Leftover files for suffixes nobody uses are not this — they break no resolution and belong to `stale-resolver-files`. Uses the same verdict `get_health` and `get_proxy_status` use, so the three cannot disagree about one machine | **Advisory — no auto-fix.** `supbuddy proxy restart` rewrites the resolver files and reloads the OS cache. The available privileged re-apply is audit-gated — it does nothing when the files are already correct, which is exactly the unloaded case — so offering it as a fix would elevate, change nothing and report success | **Fixable when resolver files are MISSING** (typically after a TLD change): `doctor --fix` writes them and re-audits to confirm. Stays **advisory** when the files exist but the OS never loaded them — the only repair available there provably does nothing, so offering it would elevate, change nothing and report success.\n| `dns-local-tld-mdns-stall` | warning | *macOS.* Managed **`.local`** domains resolve fast once and stall ~5s per concurrent lookup — macOS reserves `.local` for multicast DNS and a resolver file does not stop it. Only the IPv6 (AAAA) half stalls, so curl, a single fetch and `dig` all look healthy while a page issuing parallel requests fails with what looks like a proxy connect timeout. **Advisory.** The check measures rather than lints — 8 parallel lookups against a real mapping — so it stays silent on a machine that is genuinely unaffected. Fix by moving off `.local`: `supbuddy project set <project> --tld=test` |\n| `proxy-not-serving` | critical | The proxy should be serving and **nothing is** — Caddy is not alive, so every enabled mapping is unreachable. It stays silent when Caddy is up but a privileged step failed (HTTPS still serves on the high port there, and `pf-not-enforcing` describes that state precisely) — two contradictory critical findings would teach you to ignore both. It reads the same derived status `get_proxy_status` does, so the two can never disagree about the same machine: a deliberate `proxy stop` and an in-flight auto-restart are **not** flagged | **Advisory — no auto-fix.** The finding carries the tracked cause and names both routes back: `supbuddy proxy restart` (or Start in the app), and `SUPBUDDY_ASKPASS` when the cause is a privileged step that needs a TTY. Starting the proxy is the step that failed, so `--fix` would re-run the failing path |\n| `caddy-stuck` | critical | Caddy is alive but its admin API is wedged, so config reloads can't land | Restarts Caddy (stop → start) |\n| `caddy-ipv4-unreachable` | critical | Caddy's loaded config declares an HTTPS listener but `127.0.0.1:<port>` **refuses** connections — every IPv4 client is cut off (browsers, curl, and the pf 443→8443 redirect) while the process is up and its admin API answers | **Advisory — 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** — it reports what to import by hand rather than offering a repair that can't run |\n| `pf-not-enforcing` | critical | Port forwarding is configured but 443 isn't redirecting, so every `https://` URL on the default port is unreachable | **Fixable.** `doctor --fix` re-applies the pf ruleset (asks for your password) and then probes 443 to confirm — it reports success only if the redirect actually answers. By hand: `sudo pfctl -f /etc/pf.conf`. `supbuddy proxy restart` also re-applies it now, but only when a probe says it is genuinely broken, so an ordinary restart still prompts for nothing |\n| `duplicate-caddy-ca` | warning | *macOS.* Stale same-name `Caddy Local Authority` roots with a different key — 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 — 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 — 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) — 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 — 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 — 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 — the 401 \"Token not recognized\" state | **Advisory — 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 — no auto-fix.** Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings → Privacy & Security → Certificates → View Certificates… → 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 → General → System health → Scan** — the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see *Settings reference → 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 — projects, mappings, settings, MCP clients, project-context sync and user-skill records — 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` | …plus: 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 — `deep` unwinds what Supbuddy installed on the machine, it is not a data wipe |\n| `full` | …plus **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) — 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 — 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 → General → System health** panel points here instead.\n\n**Backup before harm.** Anything you can't regenerate — `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`) — 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** — 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 — 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 — it's the only copy.\n\n**Confirmation.** Every tier prints the **manifest** first — 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`** — `--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 — 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 — the start-on-login service, or an app-spawned daemon while the app is closed — 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 — before it asks you to type `RESET`, and before it changes anything. Quit the app (menu bar icon → 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 — 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 — there is no disk scan for stray `.supbuddy` directories — 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`, …) 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` — 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 — drag `Supbuddy.app` to the Trash — 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 → Open Dashboard → gear. Five tabs.\n\n### General\n\n- **Theme**: dark or light.\n- **Auto-start at login**: registers Supbuddy as a macOS login item. Default: on.\n- **Default TLD**: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default: `test`.\n- **Default isolation**: `host` or `thin` for newly added projects. Default: `thin` (per-project loopback IP; apps keep canonical ports like `:3000`). MCP registration additionally keeps a project on `host` when its Supabase stack is already running on the host outside Supbuddy.\n- **Auto-subdomain mapping**: when on, services and apps detected during a project scan get mappings created automatically. Default: on.\n- **Bundled-runtime trust**: installs Supbuddy's local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, …) actually read. These apps don't consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to `*.test` with `unable to get local issuer certificate`. Default: prompted on first launch when one of those tools is detected.\n - **macOS**: writes `~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plist` and calls `launchctl setenv NODE_EXTRA_CA_CERTS` so GUI-launched apps inherit it 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` to `HKCU\\Environment`.\n - **Only `NODE_EXTRA_CA_CERTS` is set session-globally**, because it is *additive* — Node appends the file to its built-in public roots, so a stale or wrong value can never strip public trust. `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` are deliberately **not** set globally: they *replace* the entire trust store, and pointing them at a local-only bundle breaks every public TLS handshake in the login session. Older builds did set them; install and every boot reconcile now actively unset them. OpenSSL/Python tools that need local trust get it per-project, from the merged public+local bundle.\n - It points at `~/Library/Application Support/Supbuddy/ca-bundle/current.crt` (or the platform equivalent), a *cumulative* concatenated PEM Supbuddy maintains — **not** Caddy's own `caddy-data/…/pki/authorities/local/root.crt`, which rotates independently. 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. Reading trust status also verifies Caddy's *active* root is actually in the bundle and re-appends it if not, so a rotation can't be missed just because the file watcher wasn't running.\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 - **Effective-value detection**: status reports the value *in effect*, not just the one Supbuddy set. `launchctl setenv` cannot retro-patch an already-running process, so an app launched before an install keeps whatever it captured and hands that to every shell and dev server it spawns — a terminal can be using a completely different CA path from the one `launchctl getenv` prints. Supbuddy samples three places: what it set, what a fresh login shell resolves, and what live processes actually hold. Divergent values are listed with the app to relaunch (and flagged when the file no longer exists — Node ignores a missing `NODE_EXTRA_CA_CERTS` silently, which presents as `unable to get local issuer certificate` with nothing to explain it).\n - **Conflict refusal**: if `NODE_EXTRA_CA_CERTS` is already set to a bundle Supbuddy doesn't own (corporate proxy, Zscaler, another vendor's CA), install refuses and surfaces the conflicting path. You can override with the explicit prompt that pops up on Install. A path Supbuddy *does* own but that isn't the current bundle — an older build's value, or Caddy's `root.crt` from a hand-rolled setup — is not a conflict: install corrects it.\n - **Quit and relaunch your AI tools** after install: the env var only takes effect for *newly-launched* processes. Install names any app still holding an older path.\n- **System health** (**Scan**): opens the **System Doctor** panel — 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 → warning → 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` — the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels.\n - **Fix…** on a fixable finding — or **Fix all (n)** in the header — 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 — 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** — 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→HTTP port and 443→HTTPS 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 **⋯** actions menu: install, edit scopes, set-primary, rotate token, revoke.\n- **Activity**: audit log with Apply/Cancel/Undo on plan rows.\n- **Trash**: soft-deleted mappings and projects, restorable for 7 days.\n- Settings: server `enabled`, `port` (default 9877), `audit_cap` (default 5000), `trash_ttl_days` (default 7).\n\n### AI Skills\n\nInstall Supbuddy's agent **skill at the user level** (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a **master on/off** plus an **autosync** toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.\n\n- **Who can install at user level**: only agents whose global file Supbuddy fully **owns** and that **self-scope** (act only when the working directory has a `.supbuddy/`): **Claude Code** (`~/.claude/skills/supbuddy/SKILL.md`) and **Cursor** (`~/.cursor/skills/supbuddy/SKILL.md`). The install is reference-counted under a synthetic `__user__` ref so it persists independent of any project and is never pruned by the boot reconcile.\n- **Master ↔ project**: the AI Skills tab is the **master** (user-level). To commit a skill into a specific repo, use that project's **AI Tools** tab and set the target to **Project** (the old `local` scope, which writes into the repo for teammates); **User** there means the master install covers it.\n- Agents whose global file holds *your own* content (Claude `CLAUDE.md`, Codex `AGENTS.md`, Copilot, Windsurf, Continue, JetBrains) are **project-level only**: a machine-wide write there could clobber your config, so they're injected per-project instead.\n\n## Tray menu\n\nThe macOS menu bar tray icon opens a menu with:\n\n- **Status: …**: current proxy state (running / idle).\n- **DNS Active (:5353)**: shown when proxy is running.\n- **LAN Sharing (\\<ip\\>)**: shown when LAN sharing is on.\n- **Tailscale (\\<ip\\>)**: shown when Tailscale is connected.\n- **Start Proxy / Stop Proxy**: opens the dashboard.\n- **Projects**: each project opens a submenu with **Apps** (click to open the mapped URL), **Supabase** services (status dot + open), and **Scripts** (your bookmarked scripts as a one-click **Start <name>** / **Stop <name>** toggle), plus **Restart Supabase**/**Restart services** and **Show in Supbuddy**.\n- **Open Dashboard**.\n- **Sync AI context for all projects**: runs the project-context sync engine for every registered project (writes `.supbuddy/`, `CLAUDE.md`, `AGENTS.md`, etc.).\n- **Show Logs**: reveals `main.log` in Finder.\n- **Check for Updates...**: manual update check (only enabled in packaged builds). The panel names all three moving parts and their versions — the **app**, the **daemon** running inside it (`bundled` when it ships with the app, `npm` when it came from the CLI package), and the **CLI** itself — because they release on their own cadences and a single unlabelled version number cannot tell you which is behind. A **CLI-only release** is detected too: the check asks npm for the newest `supbuddy` and, when yours is older, says so and gives you the command (`npx supbuddy@latest`) even though the app itself is current. In that case the panel says *\"The app is up to date\"* rather than *\"You're up to date\"*, which would not be true. A CLI version it cannot determine is shown as **unknown** rather than left blank, and a failed registry check says it failed instead of implying you are current.\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 — 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 — 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 → Network → Install Certificate**. macOS will prompt for your password. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* → System keychain → search for \"Caddy Local Authority\".\n\n### \"unable to get local issuer certificate\" / \"self signed certificate in certificate chain\" from Claude Code, Cursor, MCP servers, or other AI tools\n\nThese tools ship their own bundled JavaScript runtime (Bun, Electron, pkg-bundled Node) and ignore the system Keychain. Open **Settings → General → Bundled-runtime trust** and click **Install**. Then *fully quit and relaunch* the AI tool; the env var only takes 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` pointing at a bundle Supbuddy doesn't own (often a corporate proxy / Zscaler), so Supbuddy won't silently overwrite; use the override prompt or manually concatenate the two PEMs.\n\nIf it *still* fails after a relaunch, the process is probably not using the value `launchctl getenv` prints. Compare them:\n\n```bash\nlaunchctl getenv NODE_EXTRA_CA_CERTS # what Supbuddy set\nnode -e \"console.log(process.env.NODE_EXTRA_CA_CERTS)\" # what your shell actually has\n```\n\nIf they differ, an app launched *before* the install captured the old value and is handing it to every shell and dev server it spawns — `launchctl setenv` cannot change an already-running process. The trust panel lists the divergent value and names the app to relaunch; quitting and reopening that app (not just the terminal tab) fixes it. A value pointing at Caddy's own `caddy-data/…/pki/authorities/local/root.crt` is the classic case: that file rotates independently of Supbuddy's bundle, so the two agree until they suddenly don't.\n\n### \"Docker is not running. Please start Docker Desktop.\"\n\nCompose and Supabase features need Docker. Open Docker Desktop and wait until the whale icon stops animating.\n\n### \"Docker Compose is not installed\"\n\nCompose v2 ships inside Docker Desktop. If you removed Docker Desktop and are using a standalone Docker daemon (e.g. Colima, Rancher), install compose: `brew install docker-compose`.\n\n### \"Leftover host containers\" / \"isolation drift\" warning on a project\n\nSupbuddy flags **isolation drift** when a project's running containers don't match its configured isolation mode, for example a **Host** project with a stale `thin`-mode stack still running, or a **Thin** project with leftover host-mode containers. Switching isolation modes doesn't tear down the old layer, so those containers linger, waste resources, and can shadow the project's real stack. The warning appears in the **warnings chip** next to the enable toggle (click it to see each item; it shows a spinner while Supbuddy re-checks), as an entry in the issues counter, and as a notice on the **Supabase** tab listing the exact containers and any data volumes.\n\n**Guided cleanup.** Open the Supabase tab → **Clean up leftovers…** to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to `…/Supbuddy/backups/<project>-<timestamp>/`). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.\n\nIf the leftover copy's data looks **newer** than the active one, the warning turns red; don't delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn't manage at all (e.g. a plain `supabase start`).\n\n### MCP client says \"Invalid OAuth error\" or \"JSON Parse error: Unexpected EOF\"\n\nThe MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in **Settings → MCP → the client's ⋯ menu → Rotate token**) or you're on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include `WWW-Authenticate: Bearer` so the client doesn't fall back to OAuth.\n\n### MCP token disappeared after app restart\n\nFixed in recent builds. If you're on an older version, regenerate the token. Root cause was that `addMcpClient` didn't trigger state persistence; the client was held in memory only.\n\n### Server Actions return 403 in a Next.js app behind Supbuddy\n\nNext.js's CSRF guard rejects POSTs whose Origin isn't in `experimental.serverActions.allowedOrigins`. Supbuddy detects this and flags it in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply…** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\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` — 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…** 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** — 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 — 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→8443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click **Retry**; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off→on instead. If LAN sharing is **off**, disregard any \"LAN sharing / Bonjour\" wording in the banner; the cause is the missing forward, not mDNS.\n\n### Port 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 — 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 — 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 → Network** / **Settings → MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nUse `supbuddy reset` (see *System reset*) — 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 — 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 → General → Default TLD**. Supbuddy installs `/etc/resolver/<project-domain>` files that tell macOS to query our DNS server for that project's domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.\n\n### What happens if I delete a project?\n\nThe project moves to the Trash (visible in **Settings → MCP → Trash**) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.\n\n### How do I uninstall Supbuddy?\n\n1. Quit the app (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 — 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";
|
|
48564
|
+
const DOCS_MARKDOWN = "# Supbuddy docs\n\n> Run multiple Supabase projects at once on one Mac, each with its own custom local domain.\n\n## Getting started\n\nThere are two ways to run Supbuddy. Use the **macOS desktop app** (steps below), or the **command-line interface**, which runs on macOS and Linux. For the CLI, install it with `npx supbuddy@latest` and jump to [Command-line interface](#command-line-interface-cli). The app and the CLI share the same state, so you can use either or both.\n\n### 1. Install\n\nDownload the latest `.dmg` from the [download page](/api/download). Drag **Supbuddy.app** into `/Applications` and launch it. Supbuddy is signed and notarized; macOS will not show a Gatekeeper warning. Requires an Apple Silicon Mac (M1/M2/M3/M4, arm64). The desktop app is macOS-only in v2, but the headless CLI runs on Linux too. See [Command-line interface](#command-line-interface-cli).\n\n### 2. Trust the local Certificate Authority\n\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** — an empty proxy never generates it. With that in place, open the app and click **Install** (the first-launch prompt, or **Settings → 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 — 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 … -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 — 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 — on Firefox/Zen — 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 → General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings → Network** tab.\n\n### 3. Add your first project\n\nClick **Add project** in the Configure tab and pick a project root folder (the one with `package.json` and/or `supabase/config.toml`). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:\n\n- Supabase Kong → `api.<project>.test`\n- Supabase Studio → `studio.<project>.test`\n- Supabase Inbucket / Mailpit → `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) → `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings → General → Default TLD**.\n\n> **`.local` is fine again, from 3.5.18.** Earlier versions made every managed domain resolve slowly — a name resolved in milliseconds *once* and then stalled **five seconds per concurrent lookup**, so `curl`, a single `fetch` and `dig` all looked healthy while any page issuing several requests at once failed with what looked like a connect timeout on the proxy. The advice used to be to move off `.local`, on the grounds that macOS reserves it for multicast DNS (RFC 6762). That was only half right, and the half that mattered was ours: Supbuddy's DNS server answered only `A` for managed domains and forwarded the IPv6 (`AAAA`) lookup to the upstream resolver, which never answers for a local name — so no reply was sent at all and the client waited out its own timeout. A name on a *non-reserved* suffix stalled identically (5003 ms against `.local`'s 5002 ms), which is what proved the suffix was not the cause. Supbuddy now answers `AAAA` itself with `::1`; because that is a positive answer it also satisfies macOS's multicast rule, so `.local` resolves in single-digit milliseconds like any other suffix. (A client that prefers IPv6 is refused on `[::1]:443` and falls back to IPv4 in about 3 ms — there is deliberately no IPv6 redirect, because one was tried and it silently broke the backend HTTPS port.) **There is no need to rename your domains.** `doctor` still ships `dns-local-tld-mdns-stall` as a canary — if it fires on 3.5.18 or newer, check `supbuddy version` first, since an updated app can still be attached to an older daemon.\n\n### 4. Start the proxy\n\nToggle the project on. Supbuddy starts Caddy on port 8443 (HTTPS) and starts its built-in DNS server on port 5353. If you want real ports 80/443 instead of 8080/8443, enable **port forwarding** in **Settings → Network**. Supbuddy 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 — 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 → port pair (e.g. `api.acme.test → 54321`). Auto-generated mappings are tied to a detected service or app; you can also create manual ones.\n- **Isolation mode**: per-project. One of:\n - `thin` (lightweight, **the default for newly registered projects**): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own **port block** and a unique Compose `project_id`, written into that project's `supabase/config.toml`. That's what lets several Supabase projects run **at once on the shared daemon**, each reached by name (`api.<project>.test`, `studio.<project>.test`). Apps bind a **per-project loopback IP** (127.0.0.2, 127.0.0.3, …) so every project's dev servers keep their canonical ports — each project gets its *own* `:3000`. Start dev servers with `supbuddy run -- <dev command>` so they bind that IP. Supbuddy owns those config.toml keys while the project is `thin` and restores them the moment you switch back to `host`.\n - `host`: everything shares `127.0.0.1` and the stock ports. Dev-server ports collide across projects, and only one host-mode Supabase project can run at a time (the standard `supabase start` constraint). Use `host` **only when the project's Supabase stack is already running on the host independently of Supbuddy** (you run `supabase start` yourself and don't want Supbuddy re-porting `config.toml`). MCP registration (`register_project`) detects that case and keeps such projects on `host` automatically; in the app's Add-project dialog, pick **Host** in the Environment section yourself.\n- **Active vs inactive**: any project can be \"active\" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.\n\n## Project cards (Configure tab)\n\nEach registered project appears as a card in the Configure tab. Cards have a single-row header that's always visible and a tab-based body that expands on click.\n\n### Header\n\nReading left to right:\n\n- **Expand chevron** + **project name**: click to expand/collapse the card.\n- **Status indicator**: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem's state.\n- **Tech badges**: e.g. `TurboRepo`, `Supabase` (shown when detected).\n\n**Supabase connection warning.** When a project's app `.env` is missing the\nSupabase connection vars, or they've gone stale relative to the live target\n(e.g. after switching isolation, which republishes ports), the card shows a\n`supabase env: not connected` / `supabase env: out of date` pill. Click it to\nopen Connect and push fresh values, or choose **Ignore for this project**.\n- **Env mode chip**: read-only `Host` or `Thin` label (matching the project's isolation mode). To switch modes, open the **Supabase** tab and use the **Environment** section at the top.\n- **Issues counter**: red for errors, amber for warnings. Click to open the **issues popover** (see below). Hidden when there are no issues.\n- **Warnings chip**: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.\n- **Enable toggle** (right edge): turn the project's proxy on/off without deleting it.\n- **⋯ actions menu** (right edge): every project-level action: **Edit project**, **Rescan**, **Re-check configs** (re-runs the connection/env drift check for this project), **Select folder**, **Export bundle**, and **Delete project**.\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings. Each issue shows a severity icon, title, optional detail, and a **→ open {tab}** link. Clicking the link jumps to the relevant tab and closes the popover.\n\n### Body tabs (when expanded)\n\nThe body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header's **⋯ menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain → :port` (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed **edit** / **delete** / **access** (LAN / Tailscale state) actions and the per-mapping **toggle** to the right edge. A **Map** CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.\n\n#### Supabase (shown when Supabase is detected)\n\n**Environment section (top):** host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see [Migrating a legacy Isolated (VM) project to Thin](#migrating-a-legacy-isolated-vm-project-to-thin)).\n\n**Action bar:** Start, Stop, Restart buttons; a first-class **Connect** button (cyan, opens the connection panel for `.env` generation / merge); and a **More** menu with **Config editor** and **Details**.\n\n**Config editor: secret extraction.** When you save a `supabase/config.toml` that contains a secret-bearing value inline (e.g. an SMTP password under `[auth.email.smtp]`, an OAuth `secret`, or any `*_key`/`auth_token`), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root `.env.local`). The value is written there and replaced in `config.toml` with an `env(SUPABASE_…)` reference, so secrets never land in git. Supbuddy injects those `SUPABASE_`-prefixed values back into the `supabase start` environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)\n\n**Service rows** (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.\n\n#### Compose (shown when Compose services are detected)\n\n**Action bar:** Start, Stop, Restart. **Service rows** are read-only (status dot, name, URL). Add-on services declared in `supbuddy.addons.yml` (see **Add-on Compose services**) appear here alongside the base stack and in `get_compose_status` over MCP.\n\n#### Other (shown when non-Supabase, non-Compose services are detected)\n\nRead-only service rows: status dot, name, URL.\n\n#### Scripts (shown when scripts are detected)\n\nBookmarked scripts appear in a **Quick Access** group at the top; remaining scripts appear under **Other Scripts**. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.\n\n#### AI Tools\n\nWraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target **scope** (global / local), advanced options, and recent activity. See [Per-project AI context sync](#per-project-ai-context-sync) for what global vs. local means.\n\n> Project-level actions (**Edit**, **Rescan**, **Re-check configs**, **Select folder**, **Export bundle**, **Delete**) are no longer a tab. They live in the header's **⋯ actions menu**.\n\n---\n\n## Multiple Supabase projects (the main use case)\n\nThe reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 Inbucket). Two projects on the same machine collide; you must `supabase stop` one before `supabase start`-ing the other.\n\nTwo ways to break that constraint, picked per project in the **Supabase** tab → **Environment** section:\n\n### Thin (lightweight, recommended)\n\nSwitch a project to **Thin**. Supbuddy assigns it a free port block (in the `55000+` range), writes those ports plus a unique Compose `project_id` into its `supabase/config.toml`, and runs `supabase start` on your **normal host Docker**, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (`api.acme.test`, `studio.acme.test`, `mail.acme.test`). Switch back to **Host** and Supbuddy restores the original `config.toml` and stops just that project's stack.\n\nThis is the lightest, fastest option and the right default for most setups — which is why **newly registered projects default to Thin**. One caveat: if your `config.toml` omits a port key (e.g. `[inbucket] smtp_port`), Supbuddy can't relocate a port that isn't declared, so that one service falls back to its stock port. That is fine for a single project, but spell those keys out if two Thin projects need the same service.\n\n### Dev servers on Thin: every project keeps its own `:3000`\n\nA Thin project also gets its own **loopback IP** (127.0.0.2, 127.0.0.3, …, persisted per project). Its app dev servers bind that IP instead of `127.0.0.1`, so canonical ports never collide across projects — five Next.js apps in five projects can all run on `:3000` at once, and Supbuddy's proxy routes each `web.<project>.test` to its project's IP.\n\nStart dev servers through the launcher:\n\n```bash\nsupbuddy run -- next dev # binds -H <project loopback IP>, stays on :3000\nsupbuddy run -- vite # injects --host <ip> --strictPort\nsupbuddy run --print -- next dev # show what would run, without running it\n```\n\n`supbuddy run` reads the project's IP from the nearest `.supbuddy/meta.json` (`loopbackIp`, written when Thin is enabled), ensures the loopback alias exists, injects the right bind flag for the detected framework, and execs your command. It prints one concise line with the project's Caddy-proxied URL (e.g. `[supbuddy] → https://web.<project>.test`) — the address you should actually open. For **Next and Vite** it also hides the dev server's own `- Local:/- Network:` banner (which only echoes the raw loopback IP `127.0.0.N:<port>`, bypassing Supbuddy's HTTPS proxy): those two lines are filtered out of the piped output, every other line passes through untouched, and colours are preserved via `FORCE_COLOR` (stdin stays interactive). Other frameworks pass through with no filtering. When a project has several app mappings, it matches the one whose port equals the dev server's port (from `--port`/`-p` or the framework default), else lists them all. Make it the project's `dev` script (`\"dev\": \"supbuddy run -- next dev\"`) so nobody — humans or agents — has to remember it. **Never move an app to a nonstandard port because `127.0.0.1:3000` is busy**; that port belongs to another project's IP space.\n\n### When to stay on Host\n\nKeep a project on **Host** only when its Supabase stack runs on the host *independently of Supbuddy* — you run `supabase start` yourself on the stock ports and don't want Supbuddy rewriting `config.toml`. MCP registration (`register_project`) detects a stack like that (running containers for the project's `config.toml` `project_id`) and keeps the project on Host automatically; in the app's Add-project dialog, pick **Host** in the Environment section for such projects. Stop the stack (`supabase stop`) and switch to Thin whenever you're ready.\n\n### Running them all at once\n\nRegister as many projects as you want, and all of them can be \"active\" (proxied) at the same time. There's no limit. A Thin project's stack restarts in seconds; a Host project needs the standard `supabase start` cycle.\n\n### Migrating a legacy Isolated (VM) project to Thin\n\nIf you created a project in an older version of Supbuddy that used the now-retired **Isolated (VM)** mode, Supbuddy detects it on launch and offers a one-way, guided migration to **Thin**. The migration wizard appears in the **Supabase** tab's Environment section for any project still flagged as VM.\n\nThe migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there's no way to switch back (but your data is intact in the Thin stack).\n\nOver MCP, three tools handle the migration bridge:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode awaiting migration.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration (dump, restore, verify).\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.\n\n## Custom domains & TLDs\n\nEvery mapping resolves through Supbuddy's built-in DNS server on port 5353. By default the TLD is `.test` (an IETF-reserved TLD safe for local use). You can change the default in **Settings → General → Default TLD** to `local`, `dev`, or anything else; existing mappings are migrated to the new TLD on save.\n\nFor host resolution, Supbuddy *does not* use `/etc/hosts` for wildcards; it runs a DNS resolver. macOS's default resolver only queries port 53; Supbuddy installs a per-project resolver file under `/etc/resolver/<project-domain>` (e.g. `/etc/resolver/myapp.local`) pointing at `127.0.0.1:5353`. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like `.local` (which Bonjour/mDNS owns). You'll be prompted for sudo the first time this changes.\n\nResolver files exist only for domains the proxy actually serves — 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 → Default TLD, e.g. `.test`). A single project can opt into its **own** TLD — 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 → Network), Supbuddy binds Caddy to `0.0.0.0` instead of `127.0.0.1` and runs an mDNS responder so other machines on your local network can reach your dev servers via `<hostname>.local`. Useful for testing on your phone or another laptop without setting up Tailscale.\n\n**`.local` TLD + LAN sharing:** macOS reserves the `.local` namespace for Bonjour/mDNS (RFC 6762), and macOS's TCP stack short-circuits self-connections to your own LAN IP via the loopback path *without consulting `pf`*, so the obvious \"redirect lo0 → my LAN IP\" trick can't fix it. Supbuddy's mDNS responder works around this by **ignoring queries that originate from this machine**, letting the OS resolver fall through to `/etc/resolver/<project-domain>` (which routes to `127.0.0.1` where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: `.local` works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to `.test`, you can switch back.\n\nIf `studio.<project>.local` (or similar) doesn't load: open the Configure tab. A red banner will tell you whether it's a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.\n\n### Tailscale\n\nIf you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.\n\n## Monorepo support\n\nSupbuddy auto-detects these monorepo layouts when scanning a project root:\n\n- Turborepo (presence of `turbo.json`)\n- pnpm workspaces (`pnpm-workspace.yaml`)\n- npm/yarn workspaces (`workspaces` field in root `package.json`)\n- Common folder layouts: `apps/*`, `packages/*`, `services/*`, `sites/*`\n\nEach detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: `apps/*`, `packages/*`, `services/*`, `sites/*`, `db/`, `db/*`, `database/`, `database/*`, `packages/backend`, `packages/db`, `packages/database`.\n\n### Detected app frameworks\n\nPort detection looks for the framework dependency in `package.json` and combines that with: explicit `-p`/`--port` in the dev script, `PORT=` env in the dev script, or a config file read. If none of those resolve, the framework default is used:\n\n| Framework dependency | Default port |\n| --- | --- |\n| `next` | 3000 |\n| `vite` | 5173 |\n| `@remix-run/dev`, `@remix-run/serve` | 3000 |\n| `astro` | 4321 |\n| `nuxt`, `nuxt3` | 3000 |\n| `@sveltejs/kit` | 5173 |\n| `@angular/core` | 4200 |\n| `@nestjs/core` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | none (must be explicit in dev script) |\n\n### Server Actions allowedOrigins audit\n\nFor Next.js apps, Supbuddy reads your `next.config.{ts,mts,js,mjs,cjs}` and extracts the hosts in `experimental.serverActions.allowedOrigins`. If a mapped subdomain is missing from that list, the project's **warnings chip** flags `next.config: N origins missing`; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the **Apps** tab (the chip's \"open apps\" jump) where the affected app shows the warning with a **Fix** button.\n\nThe Fix button opens a dialog with a paste-ready snippet and an **Apply…** button: click it to see a unified diff of the change Supbuddy will make to your `next.config`, then **Confirm & write** to apply it. Supbuddy handles the four common config shapes (existing `allowedOrigins` array, existing `serverActions` block without it, existing `experimental` block without `serverActions`, or no `experimental` at all). 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 — an identifier, a function call, a ternary, `[...] as string[]` — 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 — 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) — 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 (\"…\") is not allowed.` (403). A `.your-project.local` entry counts as covering every subdomain, so an existing wildcard suffix doesn't trigger a false warning.\n\nLike the Next.js audit, the affected app's **Fix** button on the **Apps** tab opens a dialog with a paste-ready snippet and an **Apply…** button that previews a unified diff and writes `server.allowedHosts` into your `vite.config` (handling an existing `allowedHosts` array, an existing `server` block without it, or no `server` block at all; `allowedHosts: true` is left untouched). The edit is strictly additive — existing entries, spreads and comments are kept verbatim and only missing hosts are appended — 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 → MCP → Add client**, pick the client kind, and Supbuddy generates a token, edits the client's config file, and backs up the original (`<file>.supbuddy-backup` next to it). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (**Settings → MCP → 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`, plus live sync (`cloud_sync_start`, `cloud_sync_status`, `cloud_sync_stop`) — 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. `get_cloud_status` also returns a `box` summary — what the stack's box last reported doing, as a phase plus a per-unit state list, with `report_at` so the caller can age it. It is deliberately structural: the box's free-text detail is NOT included, because that text is written by whatever runs inside the box and this value reaches an agent's context. Absent (`null`) when the stack has never reported or runs an image with no reporter.\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`). Two port-forwarding fields mean different things and are reported separately: `enabled` is what you asked for, `enforced` is whether the `443 → 8443` redirect is actually live — probed, not remembered. `get_proxy_status` and `get_health` both carry the same distinction as `portForwardingEnabled` and `portForwardingEnforced`, and report `networkingDegraded: true` when the two disagree, because a redirect that is switched on and not working is an outage rather than a setting. The live probe is decisive in both directions: it overrides a stored flag that claims health, and it also clears one left behind by an abandoned repair once the redirect is confirmed working. `reload_port_forwarding` re-applies the rules with a sudo prompt and returns `ok` only once a fresh probe confirms 443 answers — a successful `pfctl` and a working redirect are not the same claim. `set_port_forwarding` deliberately returns **no `ok` field at all**: the elevation runs on the host and resolves after the tool has already replied, so it reports `requested` plus `confirmed: false` and points you at `get_port_forwarding_status`. It can still fail afterwards — a declined prompt, a timeout, or a ruleset that fails validation — and a success token there would be a guess, not an observation.\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`) — 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 — even for `auto_apply` clients — 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 — 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 → MCP → Clients** lists each client's granted scopes inline and exposes a **Scopes** button that opens the same scope editor used when adding a client.\n\n### Registering a project via MCP\n\n`register_project` takes a `root_path` (required), an optional `label`, `auto_scan` (default `true`), and an optional `isolation` (`'thin'` or `'host'`). It registers the project the same way the GUI's \"Add project\" flow does:\n\n- Derives a base domain as `<slug>.<defaultTld>` from the label (or the folder name), e.g. `staffhub.test`.\n- Records both the project `path` and `rootPath` so the project is visible to the proxy, scans, and file tools alike.\n- Scans the folder (unless `auto_scan: false`) for apps, services, scripts, and package manager.\n- Creates per-app subdomain mappings from the discovered apps (e.g. `site.staffhub.test → :3400`), derives the host service subdomains (`api.`, `studio.`, …), and reloads Caddy.\n- **Defaults to `thin` isolation**: the project gets its own loopback IP so its dev servers keep canonical ports (`:3000`) with no cross-project collisions — run them with `supbuddy run -- <dev command>`. The one exception: if the project's Supabase stack is **already running on the host outside Supbuddy**, registration keeps it on `host` (switching would rewrite its `config.toml` ports and orphan the running stack). Pass `isolation: 'host'` to opt out explicitly, or `isolation: 'thin'` to skip the detection and force thin.\n\nThe response includes an `isolation_note` explaining which mode was chosen and why — agents should read it instead of assuming.\n\n### Switching isolation over MCP\n\n`switch_isolation` ( `{ project_id, target_mode: 'host' | 'thin', auto_start? }` ) moves an existing project between **host** and **thin** mode. To-thin writes the per-project port block and `project_id` into `supabase/config.toml` and (unless `auto_start: false`) starts Supabase; to-host restores the original `config.toml` and stops that project's stack. It runs in the background and returns `{ started: true }`; poll `get_project` (`isolation`) for the current mode.\n\nA project can also be patched with `update_project`: its `patch` accepts `name`, `enabled`, `domain`, and `isolation` (it intentionally does **not** accept `path`/`rootPath`). Note that patching `isolation` only flips the flag; use `switch_isolation` to actually provision/tear down the port assignment.\n\n### Legacy VM migration over MCP\n\nFor projects still on the retired Isolated (VM) mode, three tools handle the one-way migration to Thin:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode, with their current `vmState` and migration readiness.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration. It dumps Postgres data from the VM, starts a fresh Thin stack, restores the dump, and row-count-verifies before signalling completion. Returns `{ started: true }`; poll `get_project` (`migrationState`) for progress.\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after verification passes. Errors if called before the verify step completes.\n\n### Repointing a project's Supabase config\n\n`set_supabase_config_path` ( `{ project_id, supabase_path }` ) switches which `supabase/config.toml` a project uses, for monorepos that carry more than one (e.g. a repo-root config and an app-level one). `supabase_path` is the project-relative directory **containing** the `supabase/` folder (`\".\"` for the repo root, e.g. `\"apps/getnightowls\"`). It persists the path, re-derives `supabaseProjectId` from the new config, and re-scans services. The previous stack's Docker volume is **left intact** (not deleted), so the switch is reversible; the response reports it under `orphaned_previous_stack`.\n\n### Moving a secret between env files\n\n`copy_env_var` ( `{ source_path, source_key, target_path, target_key? }` ) relocates a single variable from one env file to another (e.g. a value put in an app's `.env.local` that the stack actually injects from the repo-root `.env.local`). The value is read and written entirely inside the worker (it **never crosses the MCP boundary** and never appears in the audit log), so an agent can move a secret without it being printed. `target_key` defaults to `source_key`.\n\n### Plan / apply for destructive tools\n\nTools that delete or mutate state (`delete_mapping`, `delete_project`, `write_env_file`, etc.) return a *plan* with a preview. The MCP client (or you, in the Activity panel) explicitly calls `apply` with the `plan_id` to execute. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days.\n\n## Add-on Compose services\n\nA project can declare **extra** Docker Compose services that Supbuddy discovers, merges, runs, health-checks, and tails alongside the managed stack: a Redis cache, a worker queue, a search engine, etc. Add-on services run on the host's shared Docker daemon in both `host` and `thin` isolation, with no extra setup needed.\n\n### Declaration files & merge precedence\n\nSupbuddy looks for up to three Compose fragments in the project and merges them, later wins:\n\n1. `docker-compose.yml`: your base Compose file.\n2. `docker-compose.override.yml`: your own override, honored if present (standard Compose convention).\n3. `supbuddy.addons.yml`: Supbuddy-owned add-on fragment.\n\nAll present fragments are passed explicitly, e.g. `docker compose -f docker-compose.yml -f docker-compose.override.yml -f supbuddy.addons.yml --project-name <pinned> …`. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project's default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.\n\n### `supbuddy.addons.yml` format\n\nA valid Compose fragment (a standard `services:` map) plus an optional Supbuddy-only `x-supbuddy:` extension block. A plain `docker compose up` ignores `x-supbuddy:`, so the file stays usable without Supbuddy. Today `x-supbuddy` supports a one-shot **seed** step:\n\n```yaml\nservices:\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\nx-supbuddy:\n seed:\n service: redis\n command: [\"redis-cli\", \"ping\"] # explicit argv, runs once after services are healthy\n runOnce: true\n```\n\nThe seed step runs **once** after the add-on services are up and healthy. It's idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the `seed_addons` MCP tool.\n\n### MCP tools\n\n- `scaffold_addons` ( `{ project_id }` ): scope `config`. Creates a starter `supbuddy.addons.yml` if the project doesn't have one. Never clobbers an existing file.\n- `seed_addons` ( `{ project_id, force? }` ): scope `services`. Runs the declared `x-supbuddy.seed` step. Idempotent unless `force: true`.\n- `tail_service_logs` ( `{ project_id, service }` ): scope `log_tail`. Streams a Compose/add-on service's container logs over SSE (like `tail_request_logs`, but for container stdout/stderr).\n- `watch_supabase` ( `{ project_id }` ): scope `log_tail`. Streams a project's live Supabase start/stop/restart progress over SSE: `operation` (status + message), `progress` (image-pull/service snapshots), and `log` (raw lines, VM projects). The stream ends on a terminal status. Backs `supbuddy supabase start --follow`.\n\n### Scripts MCP tools\n\nScripts detected in a project (e.g. `dev`, `build`, `test`) are controllable over MCP:\n\n- `list_scripts` ( `{ project_id }` ): scope `read`. Returns all detected scripts with their current status and bookmark state.\n- `start_script` ( `{ project_id, script }` ): scope `services`. Starts the named script process.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process.\n- `bookmark_script` ( `{ project_id, script, bookmarked }` ): scope `services`. Pins (`bookmarked: true`) or unpins a script in the Quick Access group.\n- `tail_script_logs` ( `{ project_id, script }` ): scope `log_tail`. Streams the named script's stdout/stderr over SSE.\n\n### `get_compose_status` shape\n\n`get_compose_status` ( `{ project_id }` ) returns per-service status, not just whether Compose is installed:\n\n```json\n{\n \"project_id\": \"…\",\n \"compose_installed\": true,\n \"running\": true,\n \"services\": [\n { \"name\": \"redis\", \"status\": \"running\", \"health\": \"healthy\", \"ports\": [\"6379:6379\"], \"image\": \"redis:7-alpine\", \"container_id\": \"…\", \"source\": \"addons\" }\n ],\n \"services_source\": \"store-snapshot (updated by docker events, not probed by this call)\"\n}\n```\n\nEach service's `source` is one of `base` | `override` | `addons`, telling you which fragment declared it.\n\nThe service statuses are a **snapshot**, kept current by Supbuddy's docker-events watcher rather than probed when you call — which is why `services_source` says so. Only `compose_installed` is checked on the call itself. `get_supabase_status` reports the same way, and answers the question its name asks: `running` plus the project's Supabase services, alongside the machine-level `cli_installed` and `docker_running`.\n\n## Per-project AI context sync\n\nEach project has a **Context sync: AI tools** panel, accessible via the **AI Tools** tab in the project card, that writes a project-scoped briefing to disk so AI agents working in that repo see your live mappings, services, and isolation state without having to ask. Files written:\n\n- `.supbuddy/`: `README.md`, `mappings.md`, `services.md`, `project.md`, `mcp.md`, `do-not.md`, `docs.md`. The full live snapshot, regenerated on each sync.\n- `AGENTS.md` and `CLAUDE.md`: a small managed block prepended (or updated in place) telling the agent which project this is and pointing it at `.supbuddy/`.\n- Editor skill files when detected: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.github/copilot-instructions.md`, `.idea/supbuddy.md`.\n- `.gitignore` managed block, ignoring: `.supbuddy/meta.json` (volatile sync state), `*.supbuddy-backup-*` (rollback snapshots), and the per-editor skill files that are written **locally** (see scope below). The rest of `.supbuddy/` is intended to be committed; `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are also kept committable since you may have hand-written content there alongside Supbuddy's managed block.\n\n### Global vs. local scope\n\nThe per-editor skill files are generic Supbuddy-owned pointers (\"this is a Supbuddy project: read `.supbuddy/`, prefer the MCP tools\"). For editors that expose a **Supbuddy-owned global location**, Supbuddy writes that pointer **once, machine-wide** instead of copying it into every project, so it isn't duplicated across all your repos. Project-specific data always stays local in `.supbuddy/`.\n\n- **Claude Code** → one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** → `~/.cursor/skills/supbuddy/SKILL.md`. The global skill self-scopes: it only acts when the working directory has a `.supbuddy/` folder, and resolves the active project from that folder's `meta.json`.\n- All other targets (`windsurf`, `continue`, the `AGENTS.md`/`CLAUDE.md`/Copilot managed blocks, JetBrains) stay **local**: their \"global\" files are shared user files, so Supbuddy won't overwrite them.\n- Each target has a **scope** setting: `auto` (default: global for the Claude/Cursor skills, local for everything else), `global`, `local` (force per-project, useful if you commit the file for teammates), or `off`. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does **not** auto-remove these global files; delete them manually from `~/.claude/skills/supbuddy/` and `~/.cursor/skills/supbuddy/` if needed.\n- The always-loaded `CLAUDE.md`/`AGENTS.md` managed block stays local as a safety net so agents stay aware even if the on-demand global skill doesn't auto-activate.\n\nSync modes per project:\n\n- **Auto**: Supbuddy regenerates the files whenever mappings, services, or project state change.\n- **Manual only**: files are only written when you click **Sync now** (or use the tray's *Sync AI context for all projects*).\n- **Off**: nothing is written.\n\nThe collapsed header shows an at-a-glance status pill: mode (`auto` / `manual` / `off`), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn't present) appear greyed out in the **Detected targets** list inside the panel.\n\n## Supbuddy Cloud\n\nPush a project — its Supabase schema **and data** — to a hosted cloud dev-stack (its own full self-hosted Supabase — Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway — 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** — 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 → Cloud**.\n- **Push a project** — after signing in, each project's ⋯ menu gains **Push to cloud…**. 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** — a **☁** badge appears on its row; click it (or ⋯ → **Open in cloud**) to open the stack in the web app.\n- **CLI / MCP** — 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 ☁, and `get_project` / `list_projects` carry the `cloud` link. `cloud_teardown` (and the ⋯ teardown) destroy the remote stack and unlink it locally — routed through the same plan/apply gate as other destructive tools.\n- **Service breadth** — 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** — 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** — [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) — the code-only path; pushing a local project *with its data* still goes through the desktop app / CLI.\n\n### Live sync (local ↔ cloud)\n\nKeep a project's local directory and its cloud box in step, so you can edit locally and run in the\ncloud. Sync runs over a private Tailscale network; nothing is exposed publicly.\n\n- **Start it** — in the app, a cloud project's ⋯ menu has **Start live sync…** and **Stop live sync**.\n Starting opens a chooser: nothing is preselected and the confirm button stays disabled until you\n pick a side, because the first pass overwrites one of them.\n- **Headless** — `supbuddy cloud sync start <project> --authority=cloud|local`, plus `status` and\n `stop`. Same three as MCP tools (`cloud_sync_start` / `cloud_sync_status` / `cloud_sync_stop`).\n- **`authority` decides which side wins the FIRST pass, and that pass is one-way.** Choose `\"cloud\"`\n when the box has the truth (the usual case — the repo was cloned there) and `\"local\"` when your\n machine does. It has no default anywhere, deliberately: the named side overwrites the other, so a\n guess can delete work. After the first sync completes, the session switches to two-way automatically.\n- **What is not synced** — `.git`, `node_modules`, `.next`, `dist` and `.turbo` are ignored by default.\n `.git` in particular: the box has its own clone with its own remote, and syncing two managed copies\n of an index produces conflicts that look like repository corruption.\n- **Requirements** — sync needs the `tailscaled` and `mutagen` platform packages, which install\n automatically with the CLI on **macOS and Linux** (Intel and Apple Silicon / x86-64 and arm64).\n **Windows is not supported yet**, and Supbuddy says so rather than reporting a missing package.\n Without the packages Supbuddy reports sync as *unavailable* and everything else keeps working.\n- **Your own Tailscale is untouched.** Supbuddy runs its own tailnet daemon with a separate state file\n and socket, so joining does not log you out of a personal or work tailnet.\n- **Teardown stops sync first**, and the box's tailnet node is removed with the stack — nothing outlives\n a destroyed stack.\n- **Seeing it** — a syncing project shows its state beside the ☁ badge: *First sync…*, *In sync*,\n *n conflicts*, or *Paused — stack stopped* when the box has been idle-stopped. Nothing is shown\n for a project that is not syncing.\n- **If sync is unavailable**, everything else keeps working. Provisioning, the IDE, runners and\n teardown do not depend on the sync network; a stack simply comes up without sync and says so.\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, plus which worker the daemon is running\nsupbuddy version # which CLI build this is, and which daemon it is talking to\nsupbuddy stop # graceful shutdown\n```\n\n`supbuddy version` answers a question that used to have no answer: **which copy of the CLI is this?** Three builds exist and they look identical — the one inside the desktop app (`host`), the one from npm (`npm`), and one built from a checkout (`dev`). The build kind is stamped in at compile time, because nothing at runtime can tell them apart: the version numbers match, and a working-tree build even carries the same `daemon/worker.cjs` layout as an npm install. It prints the CLI's version, build kind and path, plus the daemon's, and warns when the two disagree — a `dev` CLI driving a shipped daemon means unreleased code is running privileged repairs against your real machine.\n\nThe names `supbuddy` and `sup` are reserved for shipped builds. A `dev` build invoked under either name **refuses to run** and explains how to find the shadowing symlink, because `pnpm link` or a hand-made symlink in a directory that precedes `/usr/local/bin` on `PATH` otherwise silently replaces the installed CLI. To run a checkout, use `./scripts/supbuddy-dev <command>` — it runs from source and needs no build. It deliberately shares the production state dir: a daemon's machine-level resources (the worker port, the Caddyfile, `/etc/hosts`, `/etc/resolver`, the pf anchor, the launchd label) are **not** state-dir scoped, so pointing a dev daemon at a private state dir does not isolate it — it only hides the running daemon from the single-daemon check, after which the dev worker takes port 48760 by killing the process holding it. Sharing the state dir keeps that check working, so `supbuddy-dev daemon` declines while the app's daemon is running. A dev CLI driving a shipped daemon prints a warning on every command.\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**After the app updates itself, it replaces an outdated daemon.** The daemon is detached, so it survives the app relaunching — without this the app would look updated while still running the previous version's worker, and any fix shipped in that worker would silently not take effect. On launch the app compares the running daemon's version (stamped into `daemon.json`) against its own: an **older** daemon is stopped and replaced, and a **newer** one is left alone and attached to, since an out-of-date app must not downgrade a running worker. If the daemon ignores the graceful stop, the app **forces it** rather than carrying on as though the stop had worked — attaching to the daemon it just judged stale is exactly how an updated app ends up running old code, and the replacement spawn would be refused anyway (\"already running\"). Shutdown is bounded from the other side too: every stop step has a timeout and the worker exits even when a service refuses to stop, because a daemon that cannot be stopped cannot be updated. A forced shutdown may leave Caddy briefly running; the health monitor reaps it and the replacement daemon takes over.\n\n### Run on login (service)\n\n```bash\nsupbuddy service install # start-on-login (launchd on macOS, systemd-user on Linux)\nsupbuddy service status\nsupbuddy service uninstall\n```\n\n### Commands\n\nAll app surfaces have a command. Names follow `supbuddy <module> <action> [args] [--flags]`. The main groups:\n\n| Group | Examples |\n| --- | --- |\n| Dev launcher | `run [--print] -- <dev command>` — on a Thin project, binds the dev server to the project's loopback IP (from `.supbuddy/meta.json`) so it keeps its canonical port (e.g. `supbuddy run -- next dev` stays on `:3000`) |\n| Health / proxy | `status`, `doctor [--fix]` (health & drift scan — see *System doctor*), `reset [--tier=soft\\|deep\\|full]` (tiered system reset — 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>` — push a project (with its Supabase data) to a hosted cloud stack; `project ls` marks pushed projects with ☁ |\n| Compose | `compose up\\|down\\|restart\\|status\\|logs <proj> [svcs]` |\n| Scripts | `scripts ls\\|start\\|stop\\|restart\\|logs\\|bookmark <proj> [script]` |\n| Isolation | `isolation switch <proj> <host\\|thin>`, `isolation pending-migrations`, `migrate start\\|finish <uuid>` |\n| Certificates | `ca status\\|install\\|uninstall` |\n| Env files | `env copy <src> <key> <target>`, `env write <path> <K=V>…` |\n| Settings | `settings get`, `settings set --json <patch>` |\n| MCP | `mcp add [<agent>]` (register Supbuddy into a coding agent: interactive, or `--write`/`--print`/`--prompt`), `mcp ls`, `mcp revoke <id>`, `mcp approvals apply\\|cancel <id>` |\n| Host / network | `connect`, `trust`, `tailscale`, `dns`, `pf` (port-forwarding) |\n| Logs | `logs requests [-f]`, `logs audit [-f]`, `logs get <id>` |\n| Account | `account`, `caps`, `addons scaffold\\|seed <proj>` |\n| Dashboard | `tui` (alias `dash`) |\n\nGlobal flags: `--json` (machine-readable output), `--yes` (skip confirmations), `--quiet`, `--url`/`--token` (attach to a specific/remote daemon instead of auto-discovery), `--state-dir` (override the shared dir), `--timeout`, and `-f`/`--follow` for streaming log commands and live `supabase start|stop|restart` progress.\n\nDestructive operations go through the same **plan → apply** gate as MCP (see *Plan / apply for destructive tools*); the CLI's control token is granted auto-apply, so they execute directly.\n\n### Live dashboard (TUI)\n\n```bash\nsupbuddy tui # or: sup dash\n```\n\n`supbuddy tui` opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project's isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press `r` to refresh, `q` to quit. It needs a running daemon (`supbuddy daemon --detach`); if none is found it tells you so.\n\n### 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 — **critical**, **warning**, **info** — each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing, so you can gate a script or CI on it.\n\n**Exit codes.** A check that can't run is an *unknown*, not a clean bill of health — so the scan reports \"I couldn't look\" separately from \"I looked and it's fine\":\n\n| Code | Meaning |\n|---|---|\n| `0` | The scan completed and found nothing critical |\n| `1` | **Critical** findings — something is definitely broken |\n| `2` | The scan **could not complete** — one or more checks never ran (see **SCAN ERRORS** in the output), so the result is an unknown |\n\nExit `2` covers cases that used to (wrongly) exit `0`: with Docker stopped, for example, every Docker-backed check fails to run, and a `0` there would tell CI the machine was healthy while part of the scan was blind. A critical finding outranks an incomplete scan — if both apply you get `1`, because that's the actionable one. Gating on \"non-zero\" catches both; check for `2` specifically if you want to start Docker and retry rather than fail the build. These codes apply to `--fix` too: a run where every repair applied but part of the scan never ran also exits `2`.\n\n`--fix` re-scans, prints a **manifest** — one line per fixable finding, taken from the scan you just saw — 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\n**An aborted `--fix` also exits non-zero (`1`).** Declining the confirmation applies nothing, so every finding is still there — exiting `0` would tell a script the machine was fine when it had just been reported as critical. This matters most where nobody actually declined: with no TTY to prompt on, `--fix` refuses on principle (confirm-before-harm), so a scripted run prints `aborted — no fixes applied` and stops. Pass `--yes` to run it unattended. A daemon-side denial of the confirmation has always exited `1`; the same outcome now gets the same code regardless of which side refused.\n\nThe doctor ships **21 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 — 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| `dns-not-resolving` | critical | Supbuddy serves these domains but the OS will not resolve them, so every mapped URL fails before it reaches the proxy — a **missing** `/etc/resolver` file, the local DNS server **not answering**, or (the case a file audit calls healthy) the files being correct while the OS has never **loaded** them. Leftover files for suffixes nobody uses are not this — they break no resolution and belong to `stale-resolver-files`. Uses the same verdict `get_health` and `get_proxy_status` use, so the three cannot disagree about one machine | **Advisory — no auto-fix.** `supbuddy proxy restart` rewrites the resolver files and reloads the OS cache. The available privileged re-apply is audit-gated — it does nothing when the files are already correct, which is exactly the unloaded case — so offering it as a fix would elevate, change nothing and report success | **Fixable when resolver files are MISSING** (typically after a TLD change): `doctor --fix` writes them and re-audits to confirm. Stays **advisory** when the files exist but the OS never loaded them — the only repair available there provably does nothing, so offering it would elevate, change nothing and report success.\n| `dns-local-tld-mdns-stall` | warning | *macOS.* Managed **`.local`** domains resolve fast once and stall ~5s per concurrent lookup — macOS reserves `.local` for multicast DNS and a resolver file does not stop it. Only the IPv6 (AAAA) half stalls, so curl, a single fetch and `dig` all look healthy while a page issuing parallel requests fails with what looks like a proxy connect timeout. **Advisory.** The check measures rather than lints — 8 parallel lookups against a real mapping — so it stays silent on a machine that is genuinely unaffected. Fix by moving off `.local`: `supbuddy project set <project> --tld=test` |\n| `proxy-not-serving` | critical | The proxy should be serving and **nothing is** — Caddy is not alive, so every enabled mapping is unreachable. It stays silent when Caddy is up but a privileged step failed (HTTPS still serves on the high port there, and `pf-not-enforcing` describes that state precisely) — two contradictory critical findings would teach you to ignore both. It reads the same derived status `get_proxy_status` does, so the two can never disagree about the same machine: a deliberate `proxy stop` and an in-flight auto-restart are **not** flagged | **Advisory — no auto-fix.** The finding carries the tracked cause and names both routes back: `supbuddy proxy restart` (or Start in the app), and `SUPBUDDY_ASKPASS` when the cause is a privileged step that needs a TTY. Starting the proxy is the step that failed, so `--fix` would re-run the failing path |\n| `caddy-stuck` | critical | Caddy is alive but its admin API is wedged, so config reloads can't land | Restarts Caddy (stop → start) |\n| `caddy-ipv4-unreachable` | critical | Caddy's loaded config declares an HTTPS listener but `127.0.0.1:<port>` **refuses** connections — every IPv4 client is cut off (browsers, curl, and the pf 443→8443 redirect) while the process is up and its admin API answers | **Advisory — 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** — it reports what to import by hand rather than offering a repair that can't run |\n| `pf-not-enforcing` | critical | Port forwarding is configured but 443 isn't redirecting, so every `https://` URL on the default port is unreachable | **Fixable.** `doctor --fix` re-applies the pf ruleset (asks for your password) and then probes 443 to confirm — it reports success only if the redirect actually answers. By hand: `sudo pfctl -f /etc/pf.conf`. `supbuddy proxy restart` also re-applies it now, but only when a probe says it is genuinely broken, so an ordinary restart still prompts for nothing |\n| `duplicate-caddy-ca` | warning | *macOS.* Stale same-name `Caddy Local Authority` roots with a different key — 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 — 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 — 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) — 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 — 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 — 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 — the 401 \"Token not recognized\" state | **Advisory — 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 — no auto-fix.** Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings → Privacy & Security → Certificates → View Certificates… → 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 → General → System health → Scan** — the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see *Settings reference → 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 — projects, mappings, settings, MCP clients, project-context sync and user-skill records — 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` | …plus: 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 — `deep` unwinds what Supbuddy installed on the machine, it is not a data wipe |\n| `full` | …plus **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) — 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 — 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 → General → System health** panel points here instead.\n\n**Backup before harm.** Anything you can't regenerate — `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`) — 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** — 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 — 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 — it's the only copy.\n\n**Confirmation.** Every tier prints the **manifest** first — 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`** — `--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 — 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 — the start-on-login service, or an app-spawned daemon while the app is closed — 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 — before it asks you to type `RESET`, and before it changes anything. Quit the app (menu bar icon → 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 — 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 — there is no disk scan for stray `.supbuddy` directories — 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`, …) 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` — 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 — drag `Supbuddy.app` to the Trash — 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 → Open Dashboard → gear. Five tabs.\n\n### General\n\n- **Theme**: dark or light.\n- **Auto-start at login**: registers Supbuddy as a macOS login item. Default: on.\n- **Default TLD**: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default: `test`.\n- **Default isolation**: `host` or `thin` for newly added projects. Default: `thin` (per-project loopback IP; apps keep canonical ports like `:3000`). MCP registration additionally keeps a project on `host` when its Supabase stack is already running on the host outside Supbuddy.\n- **Auto-subdomain mapping**: when on, services and apps detected during a project scan get mappings created automatically. Default: on.\n- **Bundled-runtime trust**: installs Supbuddy's local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, …) actually read. These apps don't consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to `*.test` with `unable to get local issuer certificate`. Default: prompted on first launch when one of those tools is detected.\n - **macOS**: writes `~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plist` and calls `launchctl setenv NODE_EXTRA_CA_CERTS` so GUI-launched apps inherit it 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` to `HKCU\\Environment`.\n - **Only `NODE_EXTRA_CA_CERTS` is set session-globally**, because it is *additive* — Node appends the file to its built-in public roots, so a stale or wrong value can never strip public trust. `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` are deliberately **not** set globally: they *replace* the entire trust store, and pointing them at a local-only bundle breaks every public TLS handshake in the login session. Older builds did set them; install and every boot reconcile now actively unset them. OpenSSL/Python tools that need local trust get it per-project, from the merged public+local bundle.\n - It points at `~/Library/Application Support/Supbuddy/ca-bundle/current.crt` (or the platform equivalent), a *cumulative* concatenated PEM Supbuddy maintains — **not** Caddy's own `caddy-data/…/pki/authorities/local/root.crt`, which rotates independently. 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. Reading trust status also verifies Caddy's *active* root is actually in the bundle and re-appends it if not, so a rotation can't be missed just because the file watcher wasn't running.\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 - **Effective-value detection**: status reports the value *in effect*, not just the one Supbuddy set. `launchctl setenv` cannot retro-patch an already-running process, so an app launched before an install keeps whatever it captured and hands that to every shell and dev server it spawns — a terminal can be using a completely different CA path from the one `launchctl getenv` prints. Supbuddy samples three places: what it set, what a fresh login shell resolves, and what live processes actually hold. Divergent values are listed with the app to relaunch (and flagged when the file no longer exists — Node ignores a missing `NODE_EXTRA_CA_CERTS` silently, which presents as `unable to get local issuer certificate` with nothing to explain it).\n - **Conflict refusal**: if `NODE_EXTRA_CA_CERTS` is already set to a bundle Supbuddy doesn't own (corporate proxy, Zscaler, another vendor's CA), install refuses and surfaces the conflicting path. You can override with the explicit prompt that pops up on Install. A path Supbuddy *does* own but that isn't the current bundle — an older build's value, or Caddy's `root.crt` from a hand-rolled setup — is not a conflict: install corrects it.\n - **Quit and relaunch your AI tools** after install: the env var only takes effect for *newly-launched* processes. Install names any app still holding an older path.\n- **System health** (**Scan**): opens the **System Doctor** panel — 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 → warning → 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` — the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels.\n - **Fix…** on a fixable finding — or **Fix all (n)** in the header — 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 — 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** — 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→HTTP port and 443→HTTPS 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 **⋯** actions menu: install, edit scopes, set-primary, rotate token, revoke.\n- **Activity**: audit log with Apply/Cancel/Undo on plan rows.\n- **Trash**: soft-deleted mappings and projects, restorable for 7 days.\n- Settings: server `enabled`, `port` (default 9877), `audit_cap` (default 5000), `trash_ttl_days` (default 7).\n\n### AI Skills\n\nInstall Supbuddy's agent **skill at the user level** (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a **master on/off** plus an **autosync** toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.\n\n- **Who can install at user level**: only agents whose global file Supbuddy fully **owns** and that **self-scope** (act only when the working directory has a `.supbuddy/`): **Claude Code** (`~/.claude/skills/supbuddy/SKILL.md`) and **Cursor** (`~/.cursor/skills/supbuddy/SKILL.md`). The install is reference-counted under a synthetic `__user__` ref so it persists independent of any project and is never pruned by the boot reconcile.\n- **Master ↔ project**: the AI Skills tab is the **master** (user-level). To commit a skill into a specific repo, use that project's **AI Tools** tab and set the target to **Project** (the old `local` scope, which writes into the repo for teammates); **User** there means the master install covers it.\n- Agents whose global file holds *your own* content (Claude `CLAUDE.md`, Codex `AGENTS.md`, Copilot, Windsurf, Continue, JetBrains) are **project-level only**: a machine-wide write there could clobber your config, so they're injected per-project instead.\n\n## Tray menu\n\nThe macOS menu bar tray icon opens a menu with:\n\n- **Status: …**: current proxy state (running / idle).\n- **DNS Active (:5353)**: shown when proxy is running.\n- **LAN Sharing (\\<ip\\>)**: shown when LAN sharing is on.\n- **Tailscale (\\<ip\\>)**: shown when Tailscale is connected.\n- **Start Proxy / Stop Proxy**: opens the dashboard.\n- **Projects**: each project opens a submenu with **Apps** (click to open the mapped URL), **Supabase** services (status dot + open), and **Scripts** (your bookmarked scripts as a one-click **Start <name>** / **Stop <name>** toggle), plus **Restart Supabase**/**Restart services** and **Show in Supbuddy**.\n- **Open Dashboard**.\n- **Sync AI context for all projects**: runs the project-context sync engine for every registered project (writes `.supbuddy/`, `CLAUDE.md`, `AGENTS.md`, etc.).\n- **Show Logs**: reveals `main.log` in Finder.\n- **Check for Updates...**: manual update check (only enabled in packaged builds). The panel names all three moving parts and their versions — the **app**, the **daemon** running inside it (`bundled` when it ships with the app, `npm` when it came from the CLI package), and the **CLI** itself — because they release on their own cadences and a single unlabelled version number cannot tell you which is behind. A **CLI-only release** is detected too: the check asks npm for the newest `supbuddy` and, when yours is older, says so and gives you the command (`npx supbuddy@latest`) even though the app itself is current. In that case the panel says *\"The app is up to date\"* rather than *\"You're up to date\"*, which would not be true. A CLI version it cannot determine is shown as **unknown** rather than left blank, and a failed registry check says it failed instead of implying you are current.\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 — 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 — 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 → Network → Install Certificate**. macOS will prompt for your password. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* → System keychain → search for \"Caddy Local Authority\".\n\n### \"unable to get local issuer certificate\" / \"self signed certificate in certificate chain\" from Claude Code, Cursor, MCP servers, or other AI tools\n\nThese tools ship their own bundled JavaScript runtime (Bun, Electron, pkg-bundled Node) and ignore the system Keychain. Open **Settings → General → Bundled-runtime trust** and click **Install**. Then *fully quit and relaunch* the AI tool; the env var only takes 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` pointing at a bundle Supbuddy doesn't own (often a corporate proxy / Zscaler), so Supbuddy won't silently overwrite; use the override prompt or manually concatenate the two PEMs.\n\nIf it *still* fails after a relaunch, the process is probably not using the value `launchctl getenv` prints. Compare them:\n\n```bash\nlaunchctl getenv NODE_EXTRA_CA_CERTS # what Supbuddy set\nnode -e \"console.log(process.env.NODE_EXTRA_CA_CERTS)\" # what your shell actually has\n```\n\nIf they differ, an app launched *before* the install captured the old value and is handing it to every shell and dev server it spawns — `launchctl setenv` cannot change an already-running process. The trust panel lists the divergent value and names the app to relaunch; quitting and reopening that app (not just the terminal tab) fixes it. A value pointing at Caddy's own `caddy-data/…/pki/authorities/local/root.crt` is the classic case: that file rotates independently of Supbuddy's bundle, so the two agree until they suddenly don't.\n\n### \"Docker is not running. Please start Docker Desktop.\"\n\nCompose and Supabase features need Docker. Open Docker Desktop and wait until the whale icon stops animating.\n\n### \"Docker Compose is not installed\"\n\nCompose v2 ships inside Docker Desktop. If you removed Docker Desktop and are using a standalone Docker daemon (e.g. Colima, Rancher), install compose: `brew install docker-compose`.\n\n### \"Leftover host containers\" / \"isolation drift\" warning on a project\n\nSupbuddy flags **isolation drift** when a project's running containers don't match its configured isolation mode, for example a **Host** project with a stale `thin`-mode stack still running, or a **Thin** project with leftover host-mode containers. Switching isolation modes doesn't tear down the old layer, so those containers linger, waste resources, and can shadow the project's real stack. The warning appears in the **warnings chip** next to the enable toggle (click it to see each item; it shows a spinner while Supbuddy re-checks), as an entry in the issues counter, and as a notice on the **Supabase** tab listing the exact containers and any data volumes.\n\n**Guided cleanup.** Open the Supabase tab → **Clean up leftovers…** to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to `…/Supbuddy/backups/<project>-<timestamp>/`). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.\n\nIf the leftover copy's data looks **newer** than the active one, the warning turns red; don't delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn't manage at all (e.g. a plain `supabase start`).\n\n### MCP client says \"Invalid OAuth error\" or \"JSON Parse error: Unexpected EOF\"\n\nThe MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in **Settings → MCP → the client's ⋯ menu → Rotate token**) or you're on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include `WWW-Authenticate: Bearer` so the client doesn't fall back to OAuth.\n\n### MCP token disappeared after app restart\n\nFixed in recent builds. If you're on an older version, regenerate the token. Root cause was that `addMcpClient` didn't trigger state persistence; the client was held in memory only.\n\n### Server Actions return 403 in a Next.js app behind Supbuddy\n\nNext.js's CSRF guard rejects POSTs whose Origin isn't in `experimental.serverActions.allowedOrigins`. Supbuddy detects this and flags it in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply…** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\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` — 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…** 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** — 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 — 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→8443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click **Retry**; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off→on instead. If LAN sharing is **off**, disregard any \"LAN sharing / Bonjour\" wording in the banner; the cause is the missing forward, not mDNS.\n\n### Port 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 — 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 — 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 → Network** / **Settings → MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nUse `supbuddy reset` (see *System reset*) — 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 — 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 → General → Default TLD**. Supbuddy installs `/etc/resolver/<project-domain>` files that tell macOS to query our DNS server for that project's domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.\n\n### What happens if I delete a project?\n\nThe project moves to the Trash (visible in **Settings → MCP → Trash**) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.\n\n### How do I uninstall Supbuddy?\n\n1. Quit the app (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 — 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";
|
|
48464
48565
|
const HEADER = (filename) => `<!-- AUTO-GENERATED BY SUPBUDDY · DO NOT EDIT (file: ${filename}) -->
|
|
48465
48566
|
`;
|
|
48466
48567
|
function renderReadme(ctx) {
|
|
@@ -48992,9 +49093,9 @@ async function removeGitignoreEntries(projectPath) {
|
|
|
48992
49093
|
const stripped = (existing.slice(0, block.startIdx) + existing.slice(block.endIdx)).replace(/\n{3,}/g, "\n\n");
|
|
48993
49094
|
await fs.writeFile(p, stripped, "utf-8");
|
|
48994
49095
|
}
|
|
48995
|
-
var define_process_env_default$
|
|
49096
|
+
var define_process_env_default$6 = {};
|
|
48996
49097
|
function globalHomeDir() {
|
|
48997
|
-
return define_process_env_default$
|
|
49098
|
+
return define_process_env_default$6.SUPBUDDY_HOME_DIR || os$1.homedir();
|
|
48998
49099
|
}
|
|
48999
49100
|
const USER_SKILL_REF = "__user__";
|
|
49000
49101
|
const TARGET_CAPABILITIES = {
|
|
@@ -49677,7 +49778,31 @@ const cloudTools = {
|
|
|
49677
49778
|
// NOTE: cloud_teardown lives in destructive.ts (routed through the plan/apply gate like other
|
|
49678
49779
|
// destructive tools) — a non-auto_apply client gets a plan preview before the remote stack is destroyed.
|
|
49679
49780
|
/** Sign in to Supbuddy Cloud (email/password) so a headless CLI/agent can push without the desktop app. */
|
|
49680
|
-
cloud_sign_in: async (a) => daemonCloudSignIn(a.email, a.password)
|
|
49781
|
+
cloud_sign_in: async (a) => daemonCloudSignIn(a.email, a.password),
|
|
49782
|
+
/**
|
|
49783
|
+
* Start live sync between a project's local directory and its cloud stack.
|
|
49784
|
+
*
|
|
49785
|
+
* `authority` is REQUIRED and deliberately has no default. The first pass is ONE-WAY: the named side
|
|
49786
|
+
* overwrites the other, so a wrong value deletes real work. That matters more here than anywhere
|
|
49787
|
+
* else in this file, because an agent calling this is not watching the directory it is about to
|
|
49788
|
+
* overwrite — so the tool refuses rather than guessing, exactly as every layer beneath it does.
|
|
49789
|
+
*
|
|
49790
|
+
* 'cloud' — the box is the truth (the usual case: the repo was cloned there)
|
|
49791
|
+
* 'local' — this machine is the truth (only when the local copy is the one to keep)
|
|
49792
|
+
*/
|
|
49793
|
+
cloud_sync_start: async (a) => {
|
|
49794
|
+
if (a.authority !== "local" && a.authority !== "cloud") {
|
|
49795
|
+
throw new Error(
|
|
49796
|
+
'cloud_sync_start requires authority to be exactly "local" or "cloud": the first sync is one-way and the named side overwrites the other, so this is never inferred.'
|
|
49797
|
+
);
|
|
49798
|
+
}
|
|
49799
|
+
const res = await daemonSyncStart(a.project_id, a.authority);
|
|
49800
|
+
return res.ok ? { ...res, __reversible_via: { tool: "cloud_sync_stop", args: { project_id: a.project_id } } } : res;
|
|
49801
|
+
},
|
|
49802
|
+
/** Sync state for a project: phase, whether it has seeded, conflicts. Null when not syncing. */
|
|
49803
|
+
cloud_sync_status: async (a) => daemonSyncStatus(a.project_id),
|
|
49804
|
+
/** Stop syncing. Idempotent — safe on a project that was never syncing. Leaves both sides as they are. */
|
|
49805
|
+
cloud_sync_stop: async (a) => daemonSyncStop(a.project_id)
|
|
49681
49806
|
};
|
|
49682
49807
|
async function handleTool(tool, args, ctx) {
|
|
49683
49808
|
const table = {
|
|
@@ -50348,7 +50473,7 @@ function pfAnchorDrifted(desired, actual) {
|
|
|
50348
50473
|
return actual !== desired;
|
|
50349
50474
|
}
|
|
50350
50475
|
const execAsync$5 = util$1.promisify(child_process.exec);
|
|
50351
|
-
const execFileAsync$
|
|
50476
|
+
const execFileAsync$3 = util$1.promisify(child_process.execFile);
|
|
50352
50477
|
const MIGRATE_DIR = path.join(os$1.tmpdir(), "supbuddy-migrate");
|
|
50353
50478
|
const COMPOSE_FILE_NAMES = [
|
|
50354
50479
|
"docker-compose.yml",
|
|
@@ -50447,7 +50572,7 @@ async function migrateToHost(project, onProgress, options = {}) {
|
|
|
50447
50572
|
`docker run --rm -v "${vol}:/source:ro" alpine tar czf - -C /source . > /tmp/${vol}.tar.gz`
|
|
50448
50573
|
], 3e5);
|
|
50449
50574
|
await execInDind(projectId, ["test", "-f", `/tmp/${vol}.tar.gz`], 5e3);
|
|
50450
|
-
await execFileAsync$
|
|
50575
|
+
await execFileAsync$3("docker", [
|
|
50451
50576
|
"cp",
|
|
50452
50577
|
`${buildContainerName(projectId)}:/tmp/${vol}.tar.gz`,
|
|
50453
50578
|
path.join(migrateDir, `${vol}.tar.gz`)
|
|
@@ -50514,12 +50639,12 @@ const isolationBridge = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.def
|
|
|
50514
50639
|
registerIsolationSwitch,
|
|
50515
50640
|
runIsolationSwitch
|
|
50516
50641
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
50517
|
-
var define_process_env_default$
|
|
50642
|
+
var define_process_env_default$5 = {};
|
|
50518
50643
|
util$1.promisify(child_process.execFile);
|
|
50519
50644
|
function getCacheDir() {
|
|
50520
|
-
const override = define_process_env_default$
|
|
50645
|
+
const override = define_process_env_default$5.SUPBUDDY_STATE_DIR;
|
|
50521
50646
|
if (override) return path.join(override, "image-cache");
|
|
50522
|
-
if (define_process_env_default$
|
|
50647
|
+
if (define_process_env_default$5.VITEST || false) {
|
|
50523
50648
|
return path.join(os$1.tmpdir(), `supbuddy-vitest-${process.pid}`, "image-cache");
|
|
50524
50649
|
}
|
|
50525
50650
|
const platform = process.platform;
|
|
@@ -50527,9 +50652,9 @@ function getCacheDir() {
|
|
|
50527
50652
|
if (platform === "darwin") {
|
|
50528
50653
|
base = path.join(os$1.homedir(), "Library", "Application Support", "Supbuddy");
|
|
50529
50654
|
} else if (platform === "win32") {
|
|
50530
|
-
base = path.join(define_process_env_default$
|
|
50655
|
+
base = path.join(define_process_env_default$5.APPDATA || path.join(os$1.homedir(), "AppData", "Roaming"), "Supbuddy");
|
|
50531
50656
|
} else {
|
|
50532
|
-
base = path.join(define_process_env_default$
|
|
50657
|
+
base = path.join(define_process_env_default$5.XDG_CONFIG_HOME || path.join(os$1.homedir(), ".config"), "Supbuddy");
|
|
50533
50658
|
}
|
|
50534
50659
|
return path.join(base, "image-cache");
|
|
50535
50660
|
}
|
|
@@ -50597,7 +50722,7 @@ async function deleteCachedImage(filename) {
|
|
|
50597
50722
|
function filenameToImage(file) {
|
|
50598
50723
|
return file.replace(/\.tar$/, "").replace(/_/g, "/");
|
|
50599
50724
|
}
|
|
50600
|
-
const execFileAsync$
|
|
50725
|
+
const execFileAsync$2 = util$1.promisify(child_process.execFile);
|
|
50601
50726
|
function assertNotUnprovisionedVm(project, op) {
|
|
50602
50727
|
if (project?.isolation === "vm") {
|
|
50603
50728
|
throw new Error(
|
|
@@ -50840,13 +50965,13 @@ async function restartSupabaseContainer(projectId, containerName) {
|
|
|
50840
50965
|
const isolated = project.isolation === "vm" && project.vmState?.status === "running";
|
|
50841
50966
|
if (isolated) {
|
|
50842
50967
|
const dind2 = buildContainerName(projectId);
|
|
50843
|
-
await execFileAsync$
|
|
50968
|
+
await execFileAsync$2(
|
|
50844
50969
|
"docker",
|
|
50845
50970
|
["exec", dind2, "docker", "restart", containerName],
|
|
50846
50971
|
{ timeout: 3e4 }
|
|
50847
50972
|
);
|
|
50848
50973
|
} else {
|
|
50849
|
-
await execFileAsync$
|
|
50974
|
+
await execFileAsync$2("docker", ["restart", containerName], { timeout: 3e4 });
|
|
50850
50975
|
}
|
|
50851
50976
|
}
|
|
50852
50977
|
const supabaseHealth = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
@@ -51115,7 +51240,7 @@ async function dumpVm(dind2, vmdb, dumpArgs, outFile) {
|
|
|
51115
51240
|
const child = node_child_process.spawn("docker", [...vmDbExec(dind2, vmdb), "pg_dump", ...dumpArgs]);
|
|
51116
51241
|
let err = "";
|
|
51117
51242
|
child.stderr.on("data", (d) => err += d.toString());
|
|
51118
|
-
const ws =
|
|
51243
|
+
const ws = fs$1.createWriteStream(outFile);
|
|
51119
51244
|
ws.on("error", reject);
|
|
51120
51245
|
child.stdout.pipe(ws);
|
|
51121
51246
|
child.on("error", reject);
|
|
@@ -51137,14 +51262,14 @@ function _pgRestoreFile(child, rs) {
|
|
|
51137
51262
|
}
|
|
51138
51263
|
async function restoreThin(thindb, restoreArgs, dumpFile) {
|
|
51139
51264
|
const child = node_child_process.spawn("docker", ["exec", "-i", "-e", "PGPASSWORD=postgres", thindb, "pg_restore", ...restoreArgs]);
|
|
51140
|
-
const rs =
|
|
51265
|
+
const rs = fs$1.createReadStream(dumpFile);
|
|
51141
51266
|
await _pgRestoreFile(child, rs);
|
|
51142
51267
|
}
|
|
51143
51268
|
async function restoreAuthStorage(thindb, dumpFile) {
|
|
51144
51269
|
const truncateSql = "SET session_replication_role='replica'; DO $$ DECLARE r record; BEGIN FOR r IN SELECT schemaname, tablename FROM pg_tables WHERE schemaname IN ('auth','storage') AND tablename NOT IN ('schema_migrations','migrations') LOOP EXECUTE format('TRUNCATE TABLE %I.%I CASCADE', r.schemaname, r.tablename); END LOOP; END $$;";
|
|
51145
51270
|
await run("docker", ["exec", "-e", "PGPASSWORD=postgres", thindb, "psql", "-U", "supabase_admin", "-d", "postgres", "-c", truncateSql], { allowFail: true });
|
|
51146
51271
|
const child = node_child_process.spawn("docker", ["exec", "-i", "-e", "PGPASSWORD=postgres", "-e", "PGOPTIONS=-c session_replication_role=replica", thindb, "pg_restore", ...buildAuthStorageRestoreArgs()]);
|
|
51147
|
-
const rs =
|
|
51272
|
+
const rs = fs$1.createReadStream(dumpFile);
|
|
51148
51273
|
await _pgRestoreFile(child, rs);
|
|
51149
51274
|
}
|
|
51150
51275
|
async function extractStorage(dindContainer, outTar) {
|
|
@@ -51153,7 +51278,7 @@ async function extractStorage(dindContainer, outTar) {
|
|
|
51153
51278
|
const innerStorageVol = innerVols[0];
|
|
51154
51279
|
await new Promise((resolve, reject) => {
|
|
51155
51280
|
const child = node_child_process.spawn("docker", buildDindStorageTarCmd(dindContainer, innerStorageVol));
|
|
51156
|
-
const ws =
|
|
51281
|
+
const ws = fs$1.createWriteStream(outTar);
|
|
51157
51282
|
ws.on("error", reject);
|
|
51158
51283
|
child.stdout.pipe(ws);
|
|
51159
51284
|
child.on("error", reject);
|
|
@@ -51170,7 +51295,7 @@ async function loadStorage(project, inTar) {
|
|
|
51170
51295
|
const thinStorageVol = `supabase_storage_${thinId}`;
|
|
51171
51296
|
await new Promise((resolve, reject) => {
|
|
51172
51297
|
const child = node_child_process.spawn("docker", buildStorageLoadArgs(thinStorageVol));
|
|
51173
|
-
const rs =
|
|
51298
|
+
const rs = fs$1.createReadStream(inTar);
|
|
51174
51299
|
rs.on("error", reject);
|
|
51175
51300
|
rs.pipe(child.stdin);
|
|
51176
51301
|
child.on("error", reject);
|
|
@@ -51193,10 +51318,10 @@ function migrateVmToThin(projectId, io2) {
|
|
|
51193
51318
|
useStore.getState().updateProject(projectId, { supabaseManaged: void 0 });
|
|
51194
51319
|
project = useStore.getState().getProject(projectId);
|
|
51195
51320
|
}
|
|
51196
|
-
const stage = await promises.mkdtemp(
|
|
51197
|
-
const publicDump =
|
|
51198
|
-
const authStorageDump =
|
|
51199
|
-
const storageTar =
|
|
51321
|
+
const stage = await promises.mkdtemp(path$1.join(os$2.tmpdir(), `supbuddy-vm-migrate-${projectId.slice(0, 8)}-`));
|
|
51322
|
+
const publicDump = path$1.join(stage, "public.dump");
|
|
51323
|
+
const authStorageDump = path$1.join(stage, "authstorage.dump");
|
|
51324
|
+
const storageTar = path$1.join(stage, "storage.tar");
|
|
51200
51325
|
const dind2 = buildContainerName(projectId);
|
|
51201
51326
|
try {
|
|
51202
51327
|
emitStep(io2, projectId, 1, TOTAL_STEPS, "reach-source", "Starting the Isolated stack to read its data…");
|
|
@@ -51273,7 +51398,7 @@ function finishVmMigration(projectId, io2) {
|
|
|
51273
51398
|
io2?.emit?.("projects-updated", useStore.getState().projects);
|
|
51274
51399
|
});
|
|
51275
51400
|
}
|
|
51276
|
-
var define_process_env_default$
|
|
51401
|
+
var define_process_env_default$4 = {};
|
|
51277
51402
|
if (typeof globalThis.crypto === "undefined") {
|
|
51278
51403
|
globalThis.crypto = crypto$1;
|
|
51279
51404
|
}
|
|
@@ -51873,7 +51998,7 @@ async function handleStartProxy() {
|
|
|
51873
51998
|
const fsd = await __vitePreload(() => import("node:fs"), false ? __VITE_PRELOAD__ : void 0);
|
|
51874
51999
|
const osd = await __vitePreload(() => import("node:os"), false ? __VITE_PRELOAD__ : void 0);
|
|
51875
52000
|
const pd = await __vitePreload(() => import("node:path"), false ? __VITE_PRELOAD__ : void 0);
|
|
51876
|
-
const dir = define_process_env_default$
|
|
52001
|
+
const dir = define_process_env_default$4.SUPBUDDY_STATE_DIR || pd.join(osd.homedir(), "Library", "Application Support", "Supbuddy");
|
|
51877
52002
|
fsd.appendFileSync(pd.join(dir, "proxy-error.log"), `[${(/* @__PURE__ */ new Date()).toISOString()}] ${error.stack}
|
|
51878
52003
|
`);
|
|
51879
52004
|
} catch {
|
|
@@ -54322,6 +54447,43 @@ process.on("message", async (message) => {
|
|
|
54322
54447
|
}
|
|
54323
54448
|
break;
|
|
54324
54449
|
}
|
|
54450
|
+
case "cloud:sync-start": {
|
|
54451
|
+
try {
|
|
54452
|
+
const projectId = message.data?.projectId;
|
|
54453
|
+
const authority = message.data?.authority;
|
|
54454
|
+
if (typeof projectId !== "string" || !projectId) throw new Error("cloud:sync-start requires a projectId");
|
|
54455
|
+
if (authority !== "local" && authority !== "cloud") {
|
|
54456
|
+
throw new Error('cloud:sync-start requires authority to be exactly "local" or "cloud"');
|
|
54457
|
+
}
|
|
54458
|
+
const data = await daemonSyncStart(projectId, authority);
|
|
54459
|
+
process.send({ type: "cloud:sync-start:response", _rid: message._rid, data });
|
|
54460
|
+
} catch (e) {
|
|
54461
|
+
process.send({ type: "cloud:sync-start:response", _rid: message._rid, error: e.message });
|
|
54462
|
+
}
|
|
54463
|
+
break;
|
|
54464
|
+
}
|
|
54465
|
+
case "cloud:sync-status": {
|
|
54466
|
+
try {
|
|
54467
|
+
const projectId = message.data?.projectId;
|
|
54468
|
+
if (typeof projectId !== "string" || !projectId) throw new Error("cloud:sync-status requires a projectId");
|
|
54469
|
+
const data = await daemonSyncStatus(projectId);
|
|
54470
|
+
process.send({ type: "cloud:sync-status:response", _rid: message._rid, data });
|
|
54471
|
+
} catch (e) {
|
|
54472
|
+
process.send({ type: "cloud:sync-status:response", _rid: message._rid, error: e.message });
|
|
54473
|
+
}
|
|
54474
|
+
break;
|
|
54475
|
+
}
|
|
54476
|
+
case "cloud:sync-stop": {
|
|
54477
|
+
try {
|
|
54478
|
+
const projectId = message.data?.projectId;
|
|
54479
|
+
if (typeof projectId !== "string" || !projectId) throw new Error("cloud:sync-stop requires a projectId");
|
|
54480
|
+
const data = await daemonSyncStop(projectId);
|
|
54481
|
+
process.send({ type: "cloud:sync-stop:response", _rid: message._rid, data });
|
|
54482
|
+
} catch (e) {
|
|
54483
|
+
process.send({ type: "cloud:sync-stop:response", _rid: message._rid, error: e.message });
|
|
54484
|
+
}
|
|
54485
|
+
break;
|
|
54486
|
+
}
|
|
54325
54487
|
case "cloud:sign-out": {
|
|
54326
54488
|
await daemonCloudSignOut();
|
|
54327
54489
|
process.send({ type: "cloud:sign-out:response", _rid: message._rid, data: { success: true } });
|
|
@@ -54527,7 +54689,7 @@ function killProcessOnPort(port) {
|
|
|
54527
54689
|
try {
|
|
54528
54690
|
const fsSync2 = require("node:fs");
|
|
54529
54691
|
const pathSync = require("node:path");
|
|
54530
|
-
const dir = define_process_env_default$
|
|
54692
|
+
const dir = define_process_env_default$4.SUPBUDDY_STATE_DIR || pathSync.join(os$1.homedir(), "Library", "Application Support", "Supbuddy");
|
|
54531
54693
|
const parsed = JSON.parse(fsSync2.readFileSync(pathSync.join(dir, "daemon.json"), "utf-8"));
|
|
54532
54694
|
recordedPid = Number.isInteger(parsed?.pid) ? parsed.pid : null;
|
|
54533
54695
|
} catch {
|
|
@@ -54719,7 +54881,7 @@ async function init() {
|
|
|
54719
54881
|
setTimeout(() => process.exit(0), 50);
|
|
54720
54882
|
};
|
|
54721
54883
|
}
|
|
54722
|
-
if (!define_process_env_default$
|
|
54884
|
+
if (!define_process_env_default$4.VITEST) {
|
|
54723
54885
|
init().catch(console.error);
|
|
54724
54886
|
}
|
|
54725
54887
|
const execAsync$2 = util$1.promisify(child_process.exec);
|
|
@@ -56651,6 +56813,478 @@ const toml = /* @__PURE__ */ _mergeNamespaces({
|
|
|
56651
56813
|
parse,
|
|
56652
56814
|
stringify
|
|
56653
56815
|
}, [toml$1]);
|
|
56816
|
+
const requireFromHere$1 = node_module.createRequire(typeof document === "undefined" ? require("url").pathToFileURL(__filename).href : _documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === "SCRIPT" && _documentCurrentScript.src || new URL("worker.cjs", document.baseURI).href);
|
|
56817
|
+
function tailscaledPackageName(platform = process.platform, arch = process.arch) {
|
|
56818
|
+
if (platform === "darwin") {
|
|
56819
|
+
return arch === "arm64" ? "@supbuddy/tailscaled-darwin-arm64" : "@supbuddy/tailscaled-darwin-amd64";
|
|
56820
|
+
}
|
|
56821
|
+
if (platform === "linux") {
|
|
56822
|
+
return arch === "arm64" ? "@supbuddy/tailscaled-linux-arm64" : "@supbuddy/tailscaled-linux-amd64";
|
|
56823
|
+
}
|
|
56824
|
+
return null;
|
|
56825
|
+
}
|
|
56826
|
+
function resolvePackagedTailscaledDir(deps = {}) {
|
|
56827
|
+
const pkgName = deps.pkgName !== void 0 ? deps.pkgName : tailscaledPackageName();
|
|
56828
|
+
if (!pkgName) return null;
|
|
56829
|
+
const resolve = deps.resolve ?? ((id) => requireFromHere$1.resolve(id));
|
|
56830
|
+
try {
|
|
56831
|
+
return path$1.dirname(resolve(`${pkgName}/package.json`));
|
|
56832
|
+
} catch {
|
|
56833
|
+
return null;
|
|
56834
|
+
}
|
|
56835
|
+
}
|
|
56836
|
+
function tailscaledBinaryNames(platform = process.platform, arch = process.arch) {
|
|
56837
|
+
if (platform !== "darwin" && platform !== "linux") return null;
|
|
56838
|
+
return { daemon: `tailscaled-${platform}-${arch}`, cli: `tailscale-${platform}-${arch}` };
|
|
56839
|
+
}
|
|
56840
|
+
function resolveTailscaledBinaries(deps = {}) {
|
|
56841
|
+
const platform = deps.platform ?? process.platform;
|
|
56842
|
+
const arch = deps.arch ?? process.arch;
|
|
56843
|
+
const dir = resolvePackagedTailscaledDir(deps);
|
|
56844
|
+
if (!dir) return null;
|
|
56845
|
+
const names = tailscaledBinaryNames(platform, arch);
|
|
56846
|
+
if (!names) return null;
|
|
56847
|
+
return { daemon: path$1.join(dir, names.daemon), cli: path$1.join(dir, names.cli) };
|
|
56848
|
+
}
|
|
56849
|
+
const requireFromHere = node_module.createRequire(typeof document === "undefined" ? require("url").pathToFileURL(__filename).href : _documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === "SCRIPT" && _documentCurrentScript.src || new URL("worker.cjs", document.baseURI).href);
|
|
56850
|
+
function mutagenPackageName(platform = process.platform, arch = process.arch) {
|
|
56851
|
+
if (platform === "darwin") {
|
|
56852
|
+
return arch === "arm64" ? "@supbuddy/mutagen-darwin-arm64" : "@supbuddy/mutagen-darwin-amd64";
|
|
56853
|
+
}
|
|
56854
|
+
if (platform === "linux") {
|
|
56855
|
+
return arch === "arm64" ? "@supbuddy/mutagen-linux-arm64" : "@supbuddy/mutagen-linux-amd64";
|
|
56856
|
+
}
|
|
56857
|
+
return null;
|
|
56858
|
+
}
|
|
56859
|
+
function resolvePackagedMutagenDir(deps = {}) {
|
|
56860
|
+
const pkgName = deps.pkgName !== void 0 ? deps.pkgName : mutagenPackageName();
|
|
56861
|
+
if (!pkgName) return null;
|
|
56862
|
+
const resolve = deps.resolve ?? ((id) => requireFromHere.resolve(id));
|
|
56863
|
+
try {
|
|
56864
|
+
return path$1.dirname(resolve(`${pkgName}/package.json`));
|
|
56865
|
+
} catch {
|
|
56866
|
+
return null;
|
|
56867
|
+
}
|
|
56868
|
+
}
|
|
56869
|
+
function mutagenBinaryName(platform = process.platform, arch = process.arch) {
|
|
56870
|
+
const a = arch === "arm64" ? "arm64" : "amd64";
|
|
56871
|
+
return `mutagen-${platform}-${a}`;
|
|
56872
|
+
}
|
|
56873
|
+
function resolveMutagenBinary(deps = {}) {
|
|
56874
|
+
const dir = resolvePackagedMutagenDir(deps);
|
|
56875
|
+
return dir ? path$1.join(dir, mutagenBinaryName()) : null;
|
|
56876
|
+
}
|
|
56877
|
+
const TAILNET_SOCKS_PORT = 1055;
|
|
56878
|
+
function tailnetPaths(dataDir) {
|
|
56879
|
+
if (!dataDir) throw new Error("tailnet paths need a data directory");
|
|
56880
|
+
const dir = path$1.join(dataDir, "tailscale");
|
|
56881
|
+
return {
|
|
56882
|
+
dir,
|
|
56883
|
+
stateFile: path$1.join(dir, "tailscaled.state"),
|
|
56884
|
+
// A socket path, not a TCP port: unix sockets are permission-scoped by the filesystem, and this
|
|
56885
|
+
// one is the full control channel of the daemon. Short by necessity — the sun_path limit is 104
|
|
56886
|
+
// bytes on macOS, and a deep data dir plus a long filename silently truncates.
|
|
56887
|
+
socket: path$1.join(dir, "ts.sock")
|
|
56888
|
+
};
|
|
56889
|
+
}
|
|
56890
|
+
function tailscaledArgs(paths, opts = {}) {
|
|
56891
|
+
return [
|
|
56892
|
+
"--tun=userspace-networking",
|
|
56893
|
+
`--state=${paths.stateFile}`,
|
|
56894
|
+
`--socket=${paths.socket}`,
|
|
56895
|
+
`--socks5-server=localhost:${opts.socksPort ?? TAILNET_SOCKS_PORT}`,
|
|
56896
|
+
// No DNS takeover and no port for the (unused) HTTP proxy. This is a background process on
|
|
56897
|
+
// someone's laptop; it gets exactly the surface sync needs.
|
|
56898
|
+
"--no-logs-no-support"
|
|
56899
|
+
];
|
|
56900
|
+
}
|
|
56901
|
+
function tailscaleCliArgs(socket2, args) {
|
|
56902
|
+
if (!socket2) throw new Error("refusing to run the tailscale CLI without an explicit socket — it would target the system daemon");
|
|
56903
|
+
return [`--socket=${socket2}`, ...args];
|
|
56904
|
+
}
|
|
56905
|
+
function tailnetUpArgs(args) {
|
|
56906
|
+
if (!args.authKey?.startsWith("tskey-")) {
|
|
56907
|
+
throw new Error("refusing to join a tailnet with a key that is not a tskey-");
|
|
56908
|
+
}
|
|
56909
|
+
if (!args.hostname) {
|
|
56910
|
+
throw new Error("refusing to join a tailnet without a hostname — an unnamed node is untraceable");
|
|
56911
|
+
}
|
|
56912
|
+
return [
|
|
56913
|
+
"up",
|
|
56914
|
+
"--authkey",
|
|
56915
|
+
args.authKey,
|
|
56916
|
+
"--hostname",
|
|
56917
|
+
args.hostname,
|
|
56918
|
+
"--accept-dns=false",
|
|
56919
|
+
"--accept-routes=false"
|
|
56920
|
+
];
|
|
56921
|
+
}
|
|
56922
|
+
function sshProxyCommand(socksPort = TAILNET_SOCKS_PORT) {
|
|
56923
|
+
return `nc -X 5 -x localhost:${socksPort} %h %p`;
|
|
56924
|
+
}
|
|
56925
|
+
function sshOptionsFor(args) {
|
|
56926
|
+
return [
|
|
56927
|
+
"-o",
|
|
56928
|
+
`ProxyCommand=${sshProxyCommand(args.socksPort)}`,
|
|
56929
|
+
"-o",
|
|
56930
|
+
`UserKnownHostsFile=${args.knownHostsFile}`,
|
|
56931
|
+
"-o",
|
|
56932
|
+
"StrictHostKeyChecking=yes",
|
|
56933
|
+
"-o",
|
|
56934
|
+
`IdentityFile=${args.identityFile}`,
|
|
56935
|
+
"-o",
|
|
56936
|
+
"IdentitiesOnly=yes",
|
|
56937
|
+
"-o",
|
|
56938
|
+
"BatchMode=yes"
|
|
56939
|
+
];
|
|
56940
|
+
}
|
|
56941
|
+
function firstSyncMode(authority) {
|
|
56942
|
+
if (authority !== "local" && authority !== "cloud") {
|
|
56943
|
+
throw new Error(
|
|
56944
|
+
`refusing to start a sync session: authoritative side must be explicitly 'local' or 'cloud', got ${JSON.stringify(authority)}. A first sync propagates deletions from the authority, so guessing it can empty the other side.`
|
|
56945
|
+
);
|
|
56946
|
+
}
|
|
56947
|
+
return "one-way-replica";
|
|
56948
|
+
}
|
|
56949
|
+
const STEADY_STATE_MODE = "two-way-safe";
|
|
56950
|
+
function classifySessionStatus(status) {
|
|
56951
|
+
const conflictCount = status.conflicts?.length ?? 0;
|
|
56952
|
+
if (status.state.startsWith("halted-on-")) {
|
|
56953
|
+
return { phase: "halted", conflictCount, haltReason: status.state.slice("halted-on-".length) };
|
|
56954
|
+
}
|
|
56955
|
+
if (status.state === "disconnected") return { phase: "connecting", conflictCount };
|
|
56956
|
+
if (conflictCount > 0) return { phase: "conflicted", conflictCount };
|
|
56957
|
+
switch (status.state) {
|
|
56958
|
+
case "watching":
|
|
56959
|
+
return { phase: "watching", conflictCount };
|
|
56960
|
+
case "connecting":
|
|
56961
|
+
return { phase: "connecting", conflictCount };
|
|
56962
|
+
case "scanning":
|
|
56963
|
+
case "reconciling":
|
|
56964
|
+
case "staging":
|
|
56965
|
+
case "transitioning":
|
|
56966
|
+
case "saving":
|
|
56967
|
+
return { phase: "syncing", conflictCount };
|
|
56968
|
+
default:
|
|
56969
|
+
return { phase: "unknown", conflictCount };
|
|
56970
|
+
}
|
|
56971
|
+
}
|
|
56972
|
+
function applyStatus(prev2, status) {
|
|
56973
|
+
const { phase, conflictCount, haltReason } = classifySessionStatus(status);
|
|
56974
|
+
return {
|
|
56975
|
+
...prev2,
|
|
56976
|
+
phase,
|
|
56977
|
+
conflictCount,
|
|
56978
|
+
haltReason,
|
|
56979
|
+
lastObservedAt: status.observedAt,
|
|
56980
|
+
// Once a session has completed a sync, it is no longer seeding — and that is a latch,
|
|
56981
|
+
// not a mirror of the current phase: a later conflict must not make it look unseeded and
|
|
56982
|
+
// re-trigger a one-way-replica that would overwrite the other side.
|
|
56983
|
+
seeded: prev2.seeded || phase === "watching"
|
|
56984
|
+
};
|
|
56985
|
+
}
|
|
56986
|
+
async function createSession(deps, args) {
|
|
56987
|
+
const mode = firstSyncMode(args.authority);
|
|
56988
|
+
const now = deps.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
56989
|
+
const [alpha, beta] = args.authority === "local" ? [args.localPath, args.remoteEndpoint] : [args.remoteEndpoint, args.localPath];
|
|
56990
|
+
const argv = [
|
|
56991
|
+
"sync",
|
|
56992
|
+
"create",
|
|
56993
|
+
"--name",
|
|
56994
|
+
sessionName(args.stackId),
|
|
56995
|
+
"--sync-mode",
|
|
56996
|
+
mode,
|
|
56997
|
+
...(args.ignore ?? []).flatMap((p) => ["--ignore", p]),
|
|
56998
|
+
alpha,
|
|
56999
|
+
beta
|
|
57000
|
+
];
|
|
57001
|
+
const run2 = await deps.runMutagen(argv);
|
|
57002
|
+
if (run2.exitCode !== 0) {
|
|
57003
|
+
throw new Error(`mutagen sync create failed (exit ${run2.exitCode}): ${run2.stderr.trim() || run2.stdout.trim()}`);
|
|
57004
|
+
}
|
|
57005
|
+
return {
|
|
57006
|
+
sessionId: sessionName(args.stackId),
|
|
57007
|
+
stackId: args.stackId,
|
|
57008
|
+
localPath: args.localPath,
|
|
57009
|
+
remoteEndpoint: args.remoteEndpoint,
|
|
57010
|
+
ignore: args.ignore,
|
|
57011
|
+
authority: args.authority,
|
|
57012
|
+
phase: "connecting",
|
|
57013
|
+
conflictCount: 0,
|
|
57014
|
+
startedAt: now,
|
|
57015
|
+
seeded: false
|
|
57016
|
+
};
|
|
57017
|
+
}
|
|
57018
|
+
function sessionName(stackId) {
|
|
57019
|
+
return `supbuddy-${stackId}`;
|
|
57020
|
+
}
|
|
57021
|
+
async function observeSession(deps, state) {
|
|
57022
|
+
const now = deps.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
57023
|
+
const run2 = await deps.runMutagen(["sync", "list", "--template", "{{json .}}", state.sessionId]);
|
|
57024
|
+
if (run2.exitCode !== 0) {
|
|
57025
|
+
const gone = /no sessions? (match|found)/i.test(run2.stderr + run2.stdout);
|
|
57026
|
+
return { ...state, phase: gone ? "stopped" : "unknown", lastObservedAt: now };
|
|
57027
|
+
}
|
|
57028
|
+
const status = parseSessionStatus(run2.stdout, now);
|
|
57029
|
+
if (!status) return { ...state, phase: "unknown", lastObservedAt: now };
|
|
57030
|
+
return applyStatus(state, status);
|
|
57031
|
+
}
|
|
57032
|
+
function parseSessionStatus(stdout, observedAt) {
|
|
57033
|
+
let parsed;
|
|
57034
|
+
try {
|
|
57035
|
+
parsed = JSON.parse(stdout);
|
|
57036
|
+
} catch {
|
|
57037
|
+
return null;
|
|
57038
|
+
}
|
|
57039
|
+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
57040
|
+
if (!first || typeof first !== "object") return null;
|
|
57041
|
+
const rec = first;
|
|
57042
|
+
const state = typeof rec.status === "string" ? rec.status : typeof rec.state === "string" ? rec.state : null;
|
|
57043
|
+
if (!state) return null;
|
|
57044
|
+
const rawConflicts = Array.isArray(rec.conflicts) ? rec.conflicts : [];
|
|
57045
|
+
const conflicts = rawConflicts.flatMap((c) => {
|
|
57046
|
+
const o = c;
|
|
57047
|
+
const path2 = typeof o?.path === "string" ? o.path : typeof o?.alphaChanges?.[0]?.path === "string" ? o.alphaChanges[0].path : null;
|
|
57048
|
+
return path2 ? [{ path: path2, details: typeof o?.details === "string" ? o.details : void 0 }] : [];
|
|
57049
|
+
});
|
|
57050
|
+
return { state, conflicts, observedAt };
|
|
57051
|
+
}
|
|
57052
|
+
async function promoteToSteadyState(deps, state) {
|
|
57053
|
+
if (!state.seeded) {
|
|
57054
|
+
throw new Error(
|
|
57055
|
+
`refusing to switch ${state.sessionId} to ${STEADY_STATE_MODE}: the session has not completed its first sync. Two-way before seeding propagates the non-authoritative side back over the authority.`
|
|
57056
|
+
);
|
|
57057
|
+
}
|
|
57058
|
+
const flush = await deps.runMutagen(["sync", "flush", state.sessionId]);
|
|
57059
|
+
if (flush.exitCode !== 0) {
|
|
57060
|
+
throw new Error(`mutagen sync flush failed (exit ${flush.exitCode}): ${flush.stderr.trim()}`);
|
|
57061
|
+
}
|
|
57062
|
+
const term = await deps.runMutagen(["sync", "terminate", state.sessionId]);
|
|
57063
|
+
if (term.exitCode !== 0) {
|
|
57064
|
+
throw new Error(`mutagen sync terminate failed (exit ${term.exitCode}): ${term.stderr.trim()}`);
|
|
57065
|
+
}
|
|
57066
|
+
const [alpha, beta] = state.authority === "local" ? [state.localPath, state.remoteEndpoint] : [state.remoteEndpoint, state.localPath];
|
|
57067
|
+
const recreate = await deps.runMutagen([
|
|
57068
|
+
"sync",
|
|
57069
|
+
"create",
|
|
57070
|
+
"--name",
|
|
57071
|
+
state.sessionId,
|
|
57072
|
+
"--sync-mode",
|
|
57073
|
+
STEADY_STATE_MODE,
|
|
57074
|
+
...(state.ignore ?? []).flatMap((p) => ["--ignore", p]),
|
|
57075
|
+
alpha,
|
|
57076
|
+
beta
|
|
57077
|
+
]);
|
|
57078
|
+
if (recreate.exitCode !== 0) {
|
|
57079
|
+
throw new Error(
|
|
57080
|
+
`mutagen sync create (${STEADY_STATE_MODE}) failed (exit ${recreate.exitCode}): ${recreate.stderr.trim() || recreate.stdout.trim()}`
|
|
57081
|
+
);
|
|
57082
|
+
}
|
|
57083
|
+
return state;
|
|
57084
|
+
}
|
|
57085
|
+
async function stopSession(deps, state) {
|
|
57086
|
+
const now = deps.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
57087
|
+
const run2 = await deps.runMutagen(["sync", "terminate", state.sessionId]);
|
|
57088
|
+
if (run2.exitCode !== 0 && !/no sessions? (match|found)/i.test(run2.stderr + run2.stdout)) {
|
|
57089
|
+
throw new Error(`mutagen sync terminate failed (exit ${run2.exitCode}): ${run2.stderr.trim()}`);
|
|
57090
|
+
}
|
|
57091
|
+
return { ...state, phase: "stopped", lastObservedAt: now };
|
|
57092
|
+
}
|
|
57093
|
+
async function startSync(deps, args) {
|
|
57094
|
+
const platform = args.platform ?? process.platform;
|
|
57095
|
+
if (!tailscaledPackageName(platform) || !mutagenPackageName(platform)) {
|
|
57096
|
+
return {
|
|
57097
|
+
ok: false,
|
|
57098
|
+
unavailable: platform === "win32" ? "live sync is not supported on Windows yet — everything else works, and a stack comes up without it" : `live sync is not supported on ${platform} — everything else works, and a stack comes up without it`
|
|
57099
|
+
};
|
|
57100
|
+
}
|
|
57101
|
+
const ts = (deps.resolveTailscaled ?? resolveTailscaledBinaries)();
|
|
57102
|
+
if (!ts) {
|
|
57103
|
+
return { ok: false, unavailable: "the tailscaled platform package is not installed, so this machine cannot join the sync network" };
|
|
57104
|
+
}
|
|
57105
|
+
const mutagen = (deps.resolveMutagen ?? resolveMutagenBinary)();
|
|
57106
|
+
if (!mutagen) {
|
|
57107
|
+
return { ok: false, unavailable: "the mutagen platform package is not installed, so files cannot be synced" };
|
|
57108
|
+
}
|
|
57109
|
+
const paths = tailnetPaths(deps.dataDir);
|
|
57110
|
+
const status = await deps.tailnetStatus(paths.socket);
|
|
57111
|
+
if (status !== "running") {
|
|
57112
|
+
if (status === "stopped") await deps.spawnDaemon(ts.daemon, tailscaledArgs(paths));
|
|
57113
|
+
const minted = await deps.fetchTailnetKey();
|
|
57114
|
+
if (!minted) {
|
|
57115
|
+
return { ok: false, unavailable: "this deployment has no sync network configured" };
|
|
57116
|
+
}
|
|
57117
|
+
const up = await deps.runCli(ts.cli, tailscaleCliArgs(paths.socket, tailnetUpArgs({
|
|
57118
|
+
authKey: minted.key,
|
|
57119
|
+
hostname: deps.hostname
|
|
57120
|
+
})));
|
|
57121
|
+
if (up.exitCode !== 0) {
|
|
57122
|
+
return { ok: false, unavailable: `could not join the sync network: ${up.stderr.replace(/tskey-[A-Za-z0-9-]+/g, "tskey-***").trim()}` };
|
|
57123
|
+
}
|
|
57124
|
+
}
|
|
57125
|
+
const cred = await deps.fetchCredentials(args.stackId);
|
|
57126
|
+
const keyFile = path$1.join(paths.dir, `${args.stackId}.key`);
|
|
57127
|
+
const knownHosts = path$1.join(paths.dir, `${args.stackId}.known_hosts`);
|
|
57128
|
+
await deps.writeFile(keyFile, cred.clientPrivateKey, 384);
|
|
57129
|
+
await deps.writeFile(
|
|
57130
|
+
knownHosts,
|
|
57131
|
+
`[${cred.tailnetHostname}]:${cred.sshPort} ${cred.hostPublicKey.split(" ").slice(0, 2).join(" ")}
|
|
57132
|
+
`,
|
|
57133
|
+
384
|
|
57134
|
+
);
|
|
57135
|
+
const shimDir = path$1.join(paths.dir, "shim");
|
|
57136
|
+
const shq = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
|
|
57137
|
+
const opts = sshOptionsFor({ knownHostsFile: knownHosts, identityFile: keyFile, socksPort: TAILNET_SOCKS_PORT }).map((a) => a === "-o" ? a : shq(a)).join(" ");
|
|
57138
|
+
for (const name2 of ["ssh", "scp"]) {
|
|
57139
|
+
await deps.writeFile(path$1.join(shimDir, name2), `#!/bin/sh
|
|
57140
|
+
exec /usr/bin/${name2} ${opts} "$@"
|
|
57141
|
+
`, 493);
|
|
57142
|
+
}
|
|
57143
|
+
const endpoint = `${cred.sshUser}@${cred.tailnetHostname}:${cred.sshPort}:${cred.workspacePath}`;
|
|
57144
|
+
const session = await createSession({ runMutagen: deps.runMutagen }, {
|
|
57145
|
+
stackId: args.stackId,
|
|
57146
|
+
localPath: args.localPath,
|
|
57147
|
+
remoteEndpoint: endpoint,
|
|
57148
|
+
authority: args.authority,
|
|
57149
|
+
ignore: args.ignore
|
|
57150
|
+
});
|
|
57151
|
+
return { ok: true, session };
|
|
57152
|
+
}
|
|
57153
|
+
var define_process_env_default$3 = {};
|
|
57154
|
+
const execFileAsync$1 = require$$0$3.promisify(node_child_process.execFile);
|
|
57155
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
57156
|
+
const shimDirFor = (dataDir) => path$1.join(tailnetPaths(dataDir).dir, "shim");
|
|
57157
|
+
const mutagenEnv = (dataDir) => ({ ...define_process_env_default$3, PATH: `${shimDirFor(dataDir)}:${define_process_env_default$3.PATH}` });
|
|
57158
|
+
function runtimeDeps(deps) {
|
|
57159
|
+
const bins = resolveTailscaledBinaries();
|
|
57160
|
+
const mutagen = resolveMutagenBinary();
|
|
57161
|
+
const paths = tailnetPaths(deps.dataDir);
|
|
57162
|
+
return {
|
|
57163
|
+
...deps,
|
|
57164
|
+
tailnetStatus: async (socket2) => {
|
|
57165
|
+
if (!fs$1.existsSync(socket2)) return "stopped";
|
|
57166
|
+
try {
|
|
57167
|
+
const { stdout } = await execFileAsync$1(bins.cli, [`--socket=${socket2}`, "status", "--json"], { timeout: 1e4 });
|
|
57168
|
+
return JSON.parse(stdout).BackendState === "Running" ? "running" : "needs-login";
|
|
57169
|
+
} catch {
|
|
57170
|
+
return "needs-login";
|
|
57171
|
+
}
|
|
57172
|
+
},
|
|
57173
|
+
spawnDaemon: async (bin, args) => {
|
|
57174
|
+
fs$1.mkdirSync(paths.dir, { recursive: true });
|
|
57175
|
+
const child = node_child_process.spawn(bin, args, { detached: true, stdio: "ignore" });
|
|
57176
|
+
child.unref();
|
|
57177
|
+
for (let i2 = 0; i2 < 60 && !fs$1.existsSync(paths.socket); i2++) await new Promise((r) => setTimeout(r, 500));
|
|
57178
|
+
if (!fs$1.existsSync(paths.socket)) throw new Error("tailscaled did not create its socket");
|
|
57179
|
+
},
|
|
57180
|
+
runCli: async (bin, args) => {
|
|
57181
|
+
try {
|
|
57182
|
+
await execFileAsync$1(bin, args, { timeout: 9e4 });
|
|
57183
|
+
return { exitCode: 0, stderr: "" };
|
|
57184
|
+
} catch (e) {
|
|
57185
|
+
const err = e;
|
|
57186
|
+
return { exitCode: err.code ?? 1, stderr: String(err.stderr ?? err.message ?? "") };
|
|
57187
|
+
}
|
|
57188
|
+
},
|
|
57189
|
+
writeFile: async (file, contents, mode) => {
|
|
57190
|
+
fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
|
|
57191
|
+
fs$1.writeFileSync(file, contents, { mode });
|
|
57192
|
+
fs$1.chmodSync(file, mode);
|
|
57193
|
+
},
|
|
57194
|
+
runMutagen: deps.runMutagen ?? (async (args) => {
|
|
57195
|
+
try {
|
|
57196
|
+
const { stdout } = await execFileAsync$1(mutagen, args, { timeout: 12e4, env: mutagenEnv(deps.dataDir) });
|
|
57197
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
57198
|
+
} catch (e) {
|
|
57199
|
+
const err = e;
|
|
57200
|
+
return { exitCode: err.code ?? 1, stdout: String(err.stdout ?? ""), stderr: String(err.stderr ?? err.message ?? "") };
|
|
57201
|
+
}
|
|
57202
|
+
})
|
|
57203
|
+
};
|
|
57204
|
+
}
|
|
57205
|
+
async function startProjectSync(deps, args) {
|
|
57206
|
+
const existing = sessions.get(args.projectId);
|
|
57207
|
+
if (existing) return { ok: true, session: existing };
|
|
57208
|
+
const res = await startSync(runtimeDeps(deps), args);
|
|
57209
|
+
if (!res.ok) return res;
|
|
57210
|
+
sessions.set(args.projectId, res.session);
|
|
57211
|
+
return res;
|
|
57212
|
+
}
|
|
57213
|
+
async function projectSyncStatus(deps, projectId, opts = {}) {
|
|
57214
|
+
const state = sessions.get(projectId);
|
|
57215
|
+
if (!state) return null;
|
|
57216
|
+
const rt = runtimeDeps(deps);
|
|
57217
|
+
let next = await observeSession({ runMutagen: rt.runMutagen }, state);
|
|
57218
|
+
if (next.seeded && next.phase === "watching" && !next.promoted) {
|
|
57219
|
+
try {
|
|
57220
|
+
next = await promoteToSteadyState({ runMutagen: rt.runMutagen }, next);
|
|
57221
|
+
next = { ...next, promoted: true };
|
|
57222
|
+
} catch (e) {
|
|
57223
|
+
next = { ...next, promoteError: e instanceof Error ? e.message : String(e) };
|
|
57224
|
+
}
|
|
57225
|
+
}
|
|
57226
|
+
if (opts.stackStopped && (next.phase === "connecting" || next.phase === "idle")) {
|
|
57227
|
+
next = { ...next, phase: "paused" };
|
|
57228
|
+
}
|
|
57229
|
+
sessions.set(projectId, next);
|
|
57230
|
+
return next;
|
|
57231
|
+
}
|
|
57232
|
+
async function stopProjectSync(deps, projectId) {
|
|
57233
|
+
const state = sessions.get(projectId);
|
|
57234
|
+
if (!state) return { stopped: false };
|
|
57235
|
+
const rt = runtimeDeps(deps);
|
|
57236
|
+
await stopSession({ runMutagen: rt.runMutagen }, state).catch(() => {
|
|
57237
|
+
});
|
|
57238
|
+
sessions.delete(projectId);
|
|
57239
|
+
return { stopped: true };
|
|
57240
|
+
}
|
|
57241
|
+
var define_process_env_default$2 = {};
|
|
57242
|
+
function syncDataDir() {
|
|
57243
|
+
const override = define_process_env_default$2.SUPBUDDY_STATE_DIR;
|
|
57244
|
+
if (override) return override;
|
|
57245
|
+
if (define_process_env_default$2.VITEST || false) {
|
|
57246
|
+
return path$1.join(os$2.tmpdir(), `supbuddy-vitest-${process.pid}`);
|
|
57247
|
+
}
|
|
57248
|
+
if (process.platform === "darwin") {
|
|
57249
|
+
return path$1.join(os$2.homedir(), "Library", "Application Support", "Supbuddy");
|
|
57250
|
+
}
|
|
57251
|
+
return path$1.join(os$2.homedir(), ".supbuddy");
|
|
57252
|
+
}
|
|
57253
|
+
function clientHostname(hostname = os$2.hostname()) {
|
|
57254
|
+
const base = hostname.split(".")[0].toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "");
|
|
57255
|
+
return `supbuddy-${base || "client"}`.slice(0, 63);
|
|
57256
|
+
}
|
|
57257
|
+
const toDaemonDeps = (d) => ({
|
|
57258
|
+
dataDir: d.dataDir ?? syncDataDir(),
|
|
57259
|
+
hostname: d.hostname ?? clientHostname(),
|
|
57260
|
+
fetchCredentials: d.fetchCredentials,
|
|
57261
|
+
fetchTailnetKey: d.fetchTailnetKey,
|
|
57262
|
+
runMutagen: d.runMutagen
|
|
57263
|
+
});
|
|
57264
|
+
async function syncStart(deps, args) {
|
|
57265
|
+
const ctx = await deps.resolveProject(args.projectId);
|
|
57266
|
+
return startProjectSync(toDaemonDeps(deps), {
|
|
57267
|
+
projectId: args.projectId,
|
|
57268
|
+
stackId: ctx.stackId,
|
|
57269
|
+
localPath: ctx.localPath,
|
|
57270
|
+
authority: args.authority,
|
|
57271
|
+
ignore: args.ignore ?? ["/.git", "/node_modules", "/.next", "/dist", "/.turbo"]
|
|
57272
|
+
});
|
|
57273
|
+
}
|
|
57274
|
+
async function syncStatus(deps, projectId, opts = {}) {
|
|
57275
|
+
return projectSyncStatus(toDaemonDeps(deps), projectId, opts);
|
|
57276
|
+
}
|
|
57277
|
+
async function syncStop(deps, projectId) {
|
|
57278
|
+
return stopProjectSync(toDaemonDeps(deps), projectId);
|
|
57279
|
+
}
|
|
57280
|
+
const projectSync = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
57281
|
+
__proto__: null,
|
|
57282
|
+
clientHostname,
|
|
57283
|
+
syncDataDir,
|
|
57284
|
+
syncStart,
|
|
57285
|
+
syncStatus,
|
|
57286
|
+
syncStop
|
|
57287
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
56654
57288
|
const execAsync$1 = util$1.promisify(child_process.exec);
|
|
56655
57289
|
const defaultRunner = async (cmd) => (await execAsync$1(cmd, { timeout: 12e4 })).stdout;
|
|
56656
57290
|
async function runCleanup(args) {
|