supbuddy 3.2.16 → 3.3.1
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 +19442 -18966
- package/dist/daemon/worker.cjs +2191 -367
- package/dist/lib.js +3 -0
- package/package.json +1 -1
package/dist/daemon/worker.cjs
CHANGED
|
@@ -33,12 +33,10 @@ const require$$0$1 = require("events");
|
|
|
33
33
|
const require$$1$1 = require("querystring");
|
|
34
34
|
const require$$2 = require("timers");
|
|
35
35
|
const require$$6$2 = require("ws");
|
|
36
|
-
const fs = require("fs/promises");
|
|
37
36
|
const child_process = require("child_process");
|
|
37
|
+
const fs = require("fs/promises");
|
|
38
38
|
const util$1 = require("util");
|
|
39
39
|
const url = require("url");
|
|
40
|
-
const net$1 = require("net");
|
|
41
|
-
const https$3 = require("https");
|
|
42
40
|
const node_child_process = require("node:child_process");
|
|
43
41
|
const promises = require("node:fs/promises");
|
|
44
42
|
const os$2 = require("node:os");
|
|
@@ -59,9 +57,11 @@ const require$$4$1 = require("node:events");
|
|
|
59
57
|
const require$$0$6 = require("node:tls");
|
|
60
58
|
const require$$2$2 = require("node:http2");
|
|
61
59
|
const module$1 = require("module");
|
|
60
|
+
const net$1 = require("net");
|
|
62
61
|
const tls$1 = require("tls");
|
|
63
62
|
const dns$3 = require("dns");
|
|
64
63
|
const dns$2 = require("dns/promises");
|
|
64
|
+
const https$3 = require("https");
|
|
65
65
|
var _documentCurrentScript = typeof document !== "undefined" ? document.currentScript : null;
|
|
66
66
|
function _interopNamespaceDefault(e) {
|
|
67
67
|
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
@@ -12338,7 +12338,7 @@ var utf8 = {
|
|
|
12338
12338
|
binaryType = null;
|
|
12339
12339
|
}
|
|
12340
12340
|
var bufferTail = data;
|
|
12341
|
-
var
|
|
12341
|
+
var buffers2 = [];
|
|
12342
12342
|
var i2;
|
|
12343
12343
|
while (bufferTail.length > 0) {
|
|
12344
12344
|
var strLen = "";
|
|
@@ -12356,12 +12356,12 @@ var utf8 = {
|
|
|
12356
12356
|
var msg2 = bufferTail.slice(1, msgLength + 1);
|
|
12357
12357
|
if (isString)
|
|
12358
12358
|
msg2 = bufferToString(msg2);
|
|
12359
|
-
|
|
12359
|
+
buffers2.push(msg2);
|
|
12360
12360
|
bufferTail = bufferTail.slice(msgLength + 1);
|
|
12361
12361
|
}
|
|
12362
|
-
var total =
|
|
12362
|
+
var total = buffers2.length;
|
|
12363
12363
|
for (i2 = 0; i2 < total; i2++) {
|
|
12364
|
-
var buffer =
|
|
12364
|
+
var buffer = buffers2[i2];
|
|
12365
12365
|
callback(decodePacket2(buffer, binaryType, true), i2, total);
|
|
12366
12366
|
}
|
|
12367
12367
|
}
|
|
@@ -13182,13 +13182,13 @@ let Polling$1 = class Polling2 extends transport_1$4.Transport {
|
|
|
13182
13182
|
*/
|
|
13183
13183
|
compress(data, encoding3, callback) {
|
|
13184
13184
|
debug$g("compressing");
|
|
13185
|
-
const
|
|
13185
|
+
const buffers2 = [];
|
|
13186
13186
|
let nread = 0;
|
|
13187
13187
|
compressionMethods$1[encoding3](this.httpCompression).on("error", callback).on("data", function(chunk) {
|
|
13188
|
-
|
|
13188
|
+
buffers2.push(chunk);
|
|
13189
13189
|
nread += chunk.length;
|
|
13190
13190
|
}).on("end", function() {
|
|
13191
|
-
callback(null, Buffer.concat(
|
|
13191
|
+
callback(null, Buffer.concat(buffers2, nread));
|
|
13192
13192
|
}).end(data);
|
|
13193
13193
|
}
|
|
13194
13194
|
/**
|
|
@@ -15622,13 +15622,13 @@ class Polling extends transport_1$1.Transport {
|
|
|
15622
15622
|
*/
|
|
15623
15623
|
compress(data, encoding3, callback) {
|
|
15624
15624
|
debug$b("compressing");
|
|
15625
|
-
const
|
|
15625
|
+
const buffers2 = [];
|
|
15626
15626
|
let nread = 0;
|
|
15627
15627
|
compressionMethods[encoding3](this.httpCompression).on("error", callback).on("data", function(chunk) {
|
|
15628
|
-
|
|
15628
|
+
buffers2.push(chunk);
|
|
15629
15629
|
nread += chunk.length;
|
|
15630
15630
|
}).on("end", function() {
|
|
15631
|
-
callback(null, Buffer.concat(
|
|
15631
|
+
callback(null, Buffer.concat(buffers2, nread));
|
|
15632
15632
|
}).end(data);
|
|
15633
15633
|
}
|
|
15634
15634
|
/**
|
|
@@ -16179,60 +16179,60 @@ function hasBinary(obj, toJSON2) {
|
|
|
16179
16179
|
return false;
|
|
16180
16180
|
}
|
|
16181
16181
|
function deconstructPacket(packet2) {
|
|
16182
|
-
const
|
|
16182
|
+
const buffers2 = [];
|
|
16183
16183
|
const packetData = packet2.data;
|
|
16184
16184
|
const pack = packet2;
|
|
16185
|
-
pack.data = _deconstructPacket(packetData,
|
|
16186
|
-
pack.attachments =
|
|
16187
|
-
return { packet: pack, buffers };
|
|
16185
|
+
pack.data = _deconstructPacket(packetData, buffers2);
|
|
16186
|
+
pack.attachments = buffers2.length;
|
|
16187
|
+
return { packet: pack, buffers: buffers2 };
|
|
16188
16188
|
}
|
|
16189
|
-
function _deconstructPacket(data,
|
|
16189
|
+
function _deconstructPacket(data, buffers2) {
|
|
16190
16190
|
if (!data)
|
|
16191
16191
|
return data;
|
|
16192
16192
|
if (isBinary(data)) {
|
|
16193
|
-
const placeholder = { _placeholder: true, num:
|
|
16194
|
-
|
|
16193
|
+
const placeholder = { _placeholder: true, num: buffers2.length };
|
|
16194
|
+
buffers2.push(data);
|
|
16195
16195
|
return placeholder;
|
|
16196
16196
|
} else if (Array.isArray(data)) {
|
|
16197
16197
|
const newData = new Array(data.length);
|
|
16198
16198
|
for (let i2 = 0; i2 < data.length; i2++) {
|
|
16199
|
-
newData[i2] = _deconstructPacket(data[i2],
|
|
16199
|
+
newData[i2] = _deconstructPacket(data[i2], buffers2);
|
|
16200
16200
|
}
|
|
16201
16201
|
return newData;
|
|
16202
16202
|
} else if (typeof data === "object" && !(data instanceof Date)) {
|
|
16203
16203
|
const newData = {};
|
|
16204
16204
|
for (const key in data) {
|
|
16205
16205
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
16206
|
-
newData[key] = _deconstructPacket(data[key],
|
|
16206
|
+
newData[key] = _deconstructPacket(data[key], buffers2);
|
|
16207
16207
|
}
|
|
16208
16208
|
}
|
|
16209
16209
|
return newData;
|
|
16210
16210
|
}
|
|
16211
16211
|
return data;
|
|
16212
16212
|
}
|
|
16213
|
-
function reconstructPacket(packet2,
|
|
16214
|
-
packet2.data = _reconstructPacket(packet2.data,
|
|
16213
|
+
function reconstructPacket(packet2, buffers2) {
|
|
16214
|
+
packet2.data = _reconstructPacket(packet2.data, buffers2);
|
|
16215
16215
|
delete packet2.attachments;
|
|
16216
16216
|
return packet2;
|
|
16217
16217
|
}
|
|
16218
|
-
function _reconstructPacket(data,
|
|
16218
|
+
function _reconstructPacket(data, buffers2) {
|
|
16219
16219
|
if (!data)
|
|
16220
16220
|
return data;
|
|
16221
16221
|
if (data && data._placeholder === true) {
|
|
16222
|
-
const isIndexValid = typeof data.num === "number" && data.num >= 0 && data.num <
|
|
16222
|
+
const isIndexValid = typeof data.num === "number" && data.num >= 0 && data.num < buffers2.length;
|
|
16223
16223
|
if (isIndexValid) {
|
|
16224
|
-
return
|
|
16224
|
+
return buffers2[data.num];
|
|
16225
16225
|
} else {
|
|
16226
16226
|
throw new Error("illegal attachments");
|
|
16227
16227
|
}
|
|
16228
16228
|
} else if (Array.isArray(data)) {
|
|
16229
16229
|
for (let i2 = 0; i2 < data.length; i2++) {
|
|
16230
|
-
data[i2] = _reconstructPacket(data[i2],
|
|
16230
|
+
data[i2] = _reconstructPacket(data[i2], buffers2);
|
|
16231
16231
|
}
|
|
16232
16232
|
} else if (typeof data === "object") {
|
|
16233
16233
|
for (const key in data) {
|
|
16234
16234
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
16235
|
-
data[key] = _reconstructPacket(data[key],
|
|
16235
|
+
data[key] = _reconstructPacket(data[key], buffers2);
|
|
16236
16236
|
}
|
|
16237
16237
|
}
|
|
16238
16238
|
}
|
|
@@ -16321,9 +16321,9 @@ class Encoder {
|
|
|
16321
16321
|
encodeAsBinary(obj) {
|
|
16322
16322
|
const deconstruction = deconstructPacket(obj);
|
|
16323
16323
|
const pack = this.encodeAsString(deconstruction.packet);
|
|
16324
|
-
const
|
|
16325
|
-
|
|
16326
|
-
return
|
|
16324
|
+
const buffers2 = deconstruction.buffers;
|
|
16325
|
+
buffers2.unshift(pack);
|
|
16326
|
+
return buffers2;
|
|
16327
16327
|
}
|
|
16328
16328
|
}
|
|
16329
16329
|
class Decoder extends Emitter {
|
|
@@ -20732,8 +20732,8 @@ const require$$18 = {
|
|
|
20732
20732
|
module2.exports.Socket = socket_12.Socket;
|
|
20733
20733
|
})(dist$1, dist$1.exports);
|
|
20734
20734
|
var distExports = dist$1.exports;
|
|
20735
|
-
const io$
|
|
20736
|
-
const { Server: Server$3, Namespace, Socket } = io$
|
|
20735
|
+
const io$2 = /* @__PURE__ */ getDefaultExportFromCjs$1(distExports);
|
|
20736
|
+
const { Server: Server$3, Namespace, Socket } = io$2;
|
|
20737
20737
|
const __vite_import_meta_env__$1 = {};
|
|
20738
20738
|
const createStoreImpl = (createState) => {
|
|
20739
20739
|
let state;
|
|
@@ -21545,6 +21545,17 @@ const MCP_TOOL_FEATURES = {
|
|
|
21545
21545
|
get_port_forwarding_status: "free",
|
|
21546
21546
|
set_port_forwarding: "pro_system",
|
|
21547
21547
|
reload_port_forwarding: "pro_system",
|
|
21548
|
+
// project readiness — can this project actually run?
|
|
21549
|
+
// A read: it reports installs and env drift and starts nothing.
|
|
21550
|
+
get_project_readiness: "free",
|
|
21551
|
+
// Starting an install runs a package manager, which executes the install scripts of
|
|
21552
|
+
// every dependency in the tree; cancelling one is the write that stops it.
|
|
21553
|
+
install_dependencies: "pro_writes",
|
|
21554
|
+
cancel_install: "pro_writes",
|
|
21555
|
+
// Reading your own install's output is a log tail, and shares the read rate bucket
|
|
21556
|
+
// with the other SSE streams — an install that cannot be watched is one a caller
|
|
21557
|
+
// polls instead, which is strictly worse for both sides.
|
|
21558
|
+
tail_install_log: "free_log_tail",
|
|
21548
21559
|
// Group 4 gap tools: misc daemon-state
|
|
21549
21560
|
cleanup_isolation: "pro_writes",
|
|
21550
21561
|
// DESTRUCTIVE — plan-gated
|
|
@@ -25706,6 +25717,18 @@ const MCP_TOOL_SCHEMAS = {
|
|
|
25706
25717
|
get_port_forwarding_status: objectType({}).strict(),
|
|
25707
25718
|
set_port_forwarding: objectType({ enabled: booleanType() }).strict(),
|
|
25708
25719
|
reload_port_forwarding: objectType({}).strict(),
|
|
25720
|
+
// — Project readiness —
|
|
25721
|
+
// `project` is a REF, not strictly an id: an id, or a name when it is unambiguous.
|
|
25722
|
+
// `ReadinessFix.args` (readiness/deps.ts) names this key, so the shape a finding hands
|
|
25723
|
+
// back is the shape the tool accepts — a fix that parses is the whole point of D1.
|
|
25724
|
+
get_project_readiness: objectType({ project: stringType().min(1) }).strict(),
|
|
25725
|
+
// `root` is an install root's project-relative path, and '' IS one — the project root,
|
|
25726
|
+
// which is the commonest fix in the repo — so it must NOT carry `.min(1)`. Omitting it
|
|
25727
|
+
// is allowed only because a single-root project needs no ceremony; the handler refuses
|
|
25728
|
+
// to pick for a project with several rather than installing the wrong one.
|
|
25729
|
+
install_dependencies: objectType({ project: stringType().min(1), root: stringType().optional() }).strict(),
|
|
25730
|
+
cancel_install: objectType({ job_id: stringType().min(1) }).strict(),
|
|
25731
|
+
tail_install_log: objectType({ job_id: stringType().min(1) }).strict(),
|
|
25709
25732
|
// — Group 4 gap tools: misc daemon-state —
|
|
25710
25733
|
// cleanup_isolation: stops/removes leftover isolation containers for a project (DESTRUCTIVE).
|
|
25711
25734
|
cleanup_isolation: objectType({
|
|
@@ -25949,6 +25972,37 @@ function resolveWorkerPort(env = typeof nodeProcess !== "undefined" ? nodeProces
|
|
|
25949
25972
|
}
|
|
25950
25973
|
return DEFAULT_WORKER_PORT;
|
|
25951
25974
|
}
|
|
25975
|
+
const DEFAULT_TARGET = { prefix: "", fileName: ".env.local" };
|
|
25976
|
+
const ENV_TARGETS = {
|
|
25977
|
+
next: { prefix: "NEXT_PUBLIC_", fileName: ".env.local" },
|
|
25978
|
+
vite: { prefix: "VITE_", fileName: ".env.local" },
|
|
25979
|
+
sveltekit: { prefix: "PUBLIC_", fileName: ".env.local" },
|
|
25980
|
+
astro: { prefix: "PUBLIC_", fileName: ".env" },
|
|
25981
|
+
nuxt: { prefix: "NUXT_PUBLIC_", fileName: ".env" },
|
|
25982
|
+
// Remix exposes env to the client through the loader/`window.ENV` pattern, not
|
|
25983
|
+
// through a build-time prefix. No prefix is the correct answer, not a gap.
|
|
25984
|
+
remix: { prefix: "", fileName: ".env" },
|
|
25985
|
+
react: DEFAULT_TARGET,
|
|
25986
|
+
node: DEFAULT_TARGET,
|
|
25987
|
+
unknown: DEFAULT_TARGET
|
|
25988
|
+
};
|
|
25989
|
+
function targetForType(type2) {
|
|
25990
|
+
if (!type2) return DEFAULT_TARGET;
|
|
25991
|
+
return ENV_TARGETS[type2] ?? DEFAULT_TARGET;
|
|
25992
|
+
}
|
|
25993
|
+
function envPrefixForType(type2) {
|
|
25994
|
+
return targetForType(type2).prefix;
|
|
25995
|
+
}
|
|
25996
|
+
function envFileNameForType(type2) {
|
|
25997
|
+
return targetForType(type2).fileName;
|
|
25998
|
+
}
|
|
25999
|
+
const ALL_ENV_PREFIXES = [
|
|
26000
|
+
"NEXT_PUBLIC_",
|
|
26001
|
+
"VITE_",
|
|
26002
|
+
"EXPO_PUBLIC_",
|
|
26003
|
+
"PUBLIC_",
|
|
26004
|
+
"NUXT_PUBLIC_"
|
|
26005
|
+
];
|
|
25952
26006
|
const LOOPBACK_RANGE_START = 2;
|
|
25953
26007
|
const LOOPBACK_RANGE_END = 254;
|
|
25954
26008
|
const LOOPBACK_RE = /^127\.0\.0\.\d{1,3}$/;
|
|
@@ -27284,8 +27338,8 @@ function formatDotenvLine(key, value) {
|
|
|
27284
27338
|
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
27285
27339
|
return `${key}="${escaped}"`;
|
|
27286
27340
|
}
|
|
27287
|
-
function stringifyDotenv(
|
|
27288
|
-
return Object.entries(
|
|
27341
|
+
function stringifyDotenv(record2) {
|
|
27342
|
+
return Object.entries(record2).map(([k, v]) => formatDotenvLine(k, v)).join("\n") + "\n";
|
|
27289
27343
|
}
|
|
27290
27344
|
async function readEnvFileAsDict(absolutePath) {
|
|
27291
27345
|
try {
|
|
@@ -28009,6 +28063,27 @@ async function resolveSupaDir(project) {
|
|
|
28009
28063
|
}
|
|
28010
28064
|
return { supaDir, configPath };
|
|
28011
28065
|
}
|
|
28066
|
+
async function ensureThinSupabaseBlock(project, opts = {}) {
|
|
28067
|
+
const resolved = await resolveSupaDir(project);
|
|
28068
|
+
if (!resolved) return null;
|
|
28069
|
+
const { supaDir, configPath } = resolved;
|
|
28070
|
+
const fs2 = await __vitePreload(() => import("fs/promises"), false ? __VITE_PRELOAD__ : void 0);
|
|
28071
|
+
const store2 = useStore.getState();
|
|
28072
|
+
const reserved = reservedSupabaseBases(store2.projects, project.id);
|
|
28073
|
+
const ports = project.supabaseManaged?.ports ?? await allocateSupabaseBlock(reserved, isPortFree);
|
|
28074
|
+
if (ports == null) throw new Error("No free Supabase port block available");
|
|
28075
|
+
const managedId = project.supabaseManaged?.projectId ?? supabaseManagedProjectId(project.name ?? "", project.id.slice(0, 8));
|
|
28076
|
+
const current = await fs2.readFile(configPath, "utf-8");
|
|
28077
|
+
const { toml: toml2, originals } = applyManagedConfig(current, ports, managedId);
|
|
28078
|
+
await opts.beforeWrite?.();
|
|
28079
|
+
await fs2.writeFile(configPath, toml2);
|
|
28080
|
+
store2.updateProject(project.id, { supabaseManaged: { projectId: managedId, ports, originals } });
|
|
28081
|
+
return { projectId: managedId, ports, configPath, supaDir, toml: toml2, originals };
|
|
28082
|
+
}
|
|
28083
|
+
async function reapplyThinSupabaseBlock(project) {
|
|
28084
|
+
if (project.isolation !== "thin") return null;
|
|
28085
|
+
return runSerial(() => ensureThinSupabaseBlock(project));
|
|
28086
|
+
}
|
|
28012
28087
|
async function enableThinSupabase(project, io2) {
|
|
28013
28088
|
const resolved = await resolveSupaDir(project);
|
|
28014
28089
|
if (!resolved) return;
|
|
@@ -28016,17 +28091,18 @@ async function enableThinSupabase(project, io2) {
|
|
|
28016
28091
|
const fs2 = await __vitePreload(() => import("fs/promises"), false ? __VITE_PRELOAD__ : void 0);
|
|
28017
28092
|
return runSerial(async () => {
|
|
28018
28093
|
const store2 = useStore.getState();
|
|
28019
|
-
const
|
|
28020
|
-
const
|
|
28021
|
-
|
|
28022
|
-
|
|
28023
|
-
|
|
28024
|
-
|
|
28025
|
-
|
|
28026
|
-
|
|
28027
|
-
|
|
28028
|
-
|
|
28029
|
-
if (
|
|
28094
|
+
const stacksBefore = countThinSupabaseStacks(store2.projects);
|
|
28095
|
+
const block = await ensureThinSupabaseBlock(project, {
|
|
28096
|
+
// Stop any OLD stack BEFORE rewriting config — it must be stopped under its
|
|
28097
|
+
// current/old project_id (the config.toml on disk still names it). No-op /
|
|
28098
|
+
// clean if nothing is running.
|
|
28099
|
+
beforeWrite: async () => {
|
|
28100
|
+
await stopSupabase(project.id, supaDir, io2).catch(() => {
|
|
28101
|
+
});
|
|
28102
|
+
}
|
|
28103
|
+
});
|
|
28104
|
+
if (!block) return;
|
|
28105
|
+
if (stacksBefore >= THIN_SUPABASE_SOFT_CAP) {
|
|
28030
28106
|
io2?.emit("supabase:operation", {
|
|
28031
28107
|
projectId: project.id,
|
|
28032
28108
|
operation: "start",
|
|
@@ -28037,7 +28113,7 @@ async function enableThinSupabase(project, io2) {
|
|
|
28037
28113
|
try {
|
|
28038
28114
|
await startSupabase(project.id, supaDir, io2);
|
|
28039
28115
|
} catch (err) {
|
|
28040
|
-
await fs2.writeFile(configPath, restoreManagedConfig(
|
|
28116
|
+
await fs2.writeFile(configPath, restoreManagedConfig(block.toml, block.originals)).catch(() => {
|
|
28041
28117
|
});
|
|
28042
28118
|
await stopSupabase(project.id, supaDir, io2).catch(() => {
|
|
28043
28119
|
});
|
|
@@ -28139,6 +28215,7 @@ const supabaseManager = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.def
|
|
|
28139
28215
|
countThinSupabaseStacks,
|
|
28140
28216
|
disableThinSupabase,
|
|
28141
28217
|
enableThinSupabase,
|
|
28218
|
+
ensureThinSupabaseBlock,
|
|
28142
28219
|
isDockerRunning,
|
|
28143
28220
|
isPortFree,
|
|
28144
28221
|
isSupabaseCLIInstalled,
|
|
@@ -28146,6 +28223,7 @@ const supabaseManager = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.def
|
|
|
28146
28223
|
isSupabaseStopInfo,
|
|
28147
28224
|
parseOwnStackPublishedPorts,
|
|
28148
28225
|
readSupabasePorts,
|
|
28226
|
+
reapplyThinSupabaseBlock,
|
|
28149
28227
|
removeSupabaseVolumes,
|
|
28150
28228
|
renameThinSupabaseStack,
|
|
28151
28229
|
reservedSupabaseBases,
|
|
@@ -31889,16 +31967,16 @@ function requireLoader() {
|
|
|
31889
31967
|
result: state.result
|
|
31890
31968
|
};
|
|
31891
31969
|
}
|
|
31892
|
-
function restoreState(state,
|
|
31893
|
-
state.position =
|
|
31894
|
-
state.line =
|
|
31895
|
-
state.lineStart =
|
|
31896
|
-
state.lineIndent =
|
|
31897
|
-
state.firstTabInLine =
|
|
31898
|
-
state.tag =
|
|
31899
|
-
state.anchor =
|
|
31900
|
-
state.kind =
|
|
31901
|
-
state.result =
|
|
31970
|
+
function restoreState(state, snapshot2) {
|
|
31971
|
+
state.position = snapshot2.position;
|
|
31972
|
+
state.line = snapshot2.line;
|
|
31973
|
+
state.lineStart = snapshot2.lineStart;
|
|
31974
|
+
state.lineIndent = snapshot2.lineIndent;
|
|
31975
|
+
state.firstTabInLine = snapshot2.firstTabInLine;
|
|
31976
|
+
state.tag = snapshot2.tag;
|
|
31977
|
+
state.anchor = snapshot2.anchor;
|
|
31978
|
+
state.kind = snapshot2.kind;
|
|
31979
|
+
state.result = snapshot2.result;
|
|
31902
31980
|
}
|
|
31903
31981
|
const directiveHandlers = {
|
|
31904
31982
|
YAML: function handleYamlDirective(state, name2, args) {
|
|
@@ -34037,8 +34115,86 @@ const composeScanner = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defi
|
|
|
34037
34115
|
resolveComposeProject,
|
|
34038
34116
|
scanDockerCompose
|
|
34039
34117
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
34040
|
-
const
|
|
34041
|
-
|
|
34118
|
+
const PACKAGE_MANAGERS = {
|
|
34119
|
+
pnpm: true,
|
|
34120
|
+
yarn: true,
|
|
34121
|
+
npm: true,
|
|
34122
|
+
bun: true
|
|
34123
|
+
};
|
|
34124
|
+
const PACKAGE_MANAGER_NAMES = Object.keys(
|
|
34125
|
+
PACKAGE_MANAGERS
|
|
34126
|
+
);
|
|
34127
|
+
const MANAGER_ALTERNATION = PACKAGE_MANAGER_NAMES.join("|");
|
|
34128
|
+
function parsePackageManagerField(raw) {
|
|
34129
|
+
if (typeof raw !== "string") return null;
|
|
34130
|
+
const match = new RegExp(`^(${MANAGER_ALTERNATION})@(\\d[^+\\s]*)(?:\\+[\\w.+-]+)?$`).exec(
|
|
34131
|
+
raw.trim()
|
|
34132
|
+
);
|
|
34133
|
+
if (!match) return null;
|
|
34134
|
+
return { manager: match[1], version: match[2] };
|
|
34135
|
+
}
|
|
34136
|
+
const LOCKFILE_PRECEDENCE = [
|
|
34137
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
34138
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
34139
|
+
{ file: "bun.lock", manager: "bun" },
|
|
34140
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
34141
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
34142
|
+
{ file: "npm-shrinkwrap.json", manager: "npm" }
|
|
34143
|
+
];
|
|
34144
|
+
const LOCKFILE_NAMES = LOCKFILE_PRECEDENCE.map((entry) => entry.file);
|
|
34145
|
+
const LOCKFILES_BY_MANAGER = LOCKFILE_PRECEDENCE.reduce(
|
|
34146
|
+
(acc, entry) => {
|
|
34147
|
+
acc[entry.manager].push(entry.file);
|
|
34148
|
+
return acc;
|
|
34149
|
+
},
|
|
34150
|
+
{ pnpm: [], yarn: [], npm: [], bun: [] }
|
|
34151
|
+
);
|
|
34152
|
+
async function detectPackageManagerAt(dir, io2) {
|
|
34153
|
+
const raw = await io2.readFile(path.posix.join(dir, "package.json"));
|
|
34154
|
+
if (raw !== null) {
|
|
34155
|
+
let field;
|
|
34156
|
+
try {
|
|
34157
|
+
field = JSON.parse(raw).packageManager;
|
|
34158
|
+
} catch {
|
|
34159
|
+
field = void 0;
|
|
34160
|
+
}
|
|
34161
|
+
const parsed = parsePackageManagerField(field);
|
|
34162
|
+
if (parsed) {
|
|
34163
|
+
return {
|
|
34164
|
+
manager: parsed.manager,
|
|
34165
|
+
managerSource: "packageManager-field",
|
|
34166
|
+
managerVersion: parsed.version
|
|
34167
|
+
};
|
|
34168
|
+
}
|
|
34169
|
+
}
|
|
34170
|
+
const entries = new Set(await io2.readDir(dir));
|
|
34171
|
+
for (const candidate of LOCKFILE_PRECEDENCE) {
|
|
34172
|
+
if (entries.has(candidate.file)) {
|
|
34173
|
+
return { manager: candidate.manager, managerSource: "lockfile" };
|
|
34174
|
+
}
|
|
34175
|
+
}
|
|
34176
|
+
return null;
|
|
34177
|
+
}
|
|
34178
|
+
function installArgvFor(manager) {
|
|
34179
|
+
return [manager, "install"];
|
|
34180
|
+
}
|
|
34181
|
+
const PNP_MARKERS = [".pnp.cjs", ".pnp.loader.mjs"];
|
|
34182
|
+
async function detectNodeLinkerAt(dir, manager, io2) {
|
|
34183
|
+
if (manager !== "yarn") return "node-modules";
|
|
34184
|
+
const entries = new Set(await io2.readDir(dir));
|
|
34185
|
+
if (PNP_MARKERS.some((marker) => entries.has(marker))) return "pnp";
|
|
34186
|
+
if (!entries.has(".yarnrc.yml")) return "node-modules";
|
|
34187
|
+
const yarnrc = await io2.readFile(path.posix.join(dir, ".yarnrc.yml"));
|
|
34188
|
+
const linker = yarnrc ? /^nodeLinker:\s*(\S+)/m.exec(yarnrc)?.[1] : void 0;
|
|
34189
|
+
return linker === void 0 || linker === "pnp" ? "pnp" : "node-modules";
|
|
34190
|
+
}
|
|
34191
|
+
const STOP_GRACE_MS = 5e3;
|
|
34192
|
+
class SpawnNoPidError extends Error {
|
|
34193
|
+
constructor(message) {
|
|
34194
|
+
super(message);
|
|
34195
|
+
this.name = "SpawnNoPidError";
|
|
34196
|
+
}
|
|
34197
|
+
}
|
|
34042
34198
|
function compareNodeVersions(a, b) {
|
|
34043
34199
|
const parts = (v) => v.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
34044
34200
|
const [x, y] = [parts(a), parts(b)];
|
|
@@ -34077,27 +34233,140 @@ function getAugmentedPath() {
|
|
|
34077
34233
|
const missing = extraPaths.filter((p) => p && !currentPath.includes(p));
|
|
34078
34234
|
return missing.length > 0 ? [...missing, currentPath].filter(Boolean).join(":") : currentPath;
|
|
34079
34235
|
}
|
|
34080
|
-
|
|
34081
|
-
|
|
34082
|
-
|
|
34083
|
-
|
|
34084
|
-
|
|
34085
|
-
}
|
|
34086
|
-
if (files.includes("yarn.lock")) {
|
|
34087
|
-
return "yarn";
|
|
34236
|
+
function signalTree(child, pid, signal) {
|
|
34237
|
+
const single = () => {
|
|
34238
|
+
try {
|
|
34239
|
+
child.kill(signal);
|
|
34240
|
+
} catch {
|
|
34088
34241
|
}
|
|
34089
|
-
|
|
34090
|
-
|
|
34091
|
-
|
|
34242
|
+
};
|
|
34243
|
+
if (nodeProcess.platform === "win32") {
|
|
34244
|
+
single();
|
|
34245
|
+
return;
|
|
34246
|
+
}
|
|
34247
|
+
try {
|
|
34248
|
+
nodeProcess.kill(-pid, signal);
|
|
34249
|
+
} catch (error) {
|
|
34250
|
+
if (error.code === "ESRCH") return;
|
|
34251
|
+
single();
|
|
34092
34252
|
}
|
|
34093
34253
|
}
|
|
34254
|
+
function spawnProjectCommand(opts) {
|
|
34255
|
+
const { cwd, argv, env, onLine, onExit, onError } = opts;
|
|
34256
|
+
if (argv.length === 0) {
|
|
34257
|
+
throw new Error("spawnProjectCommand requires a non-empty argv");
|
|
34258
|
+
}
|
|
34259
|
+
const child = child_process.spawn(argv[0], argv.slice(1), {
|
|
34260
|
+
cwd,
|
|
34261
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
34262
|
+
shell: true,
|
|
34263
|
+
// A PROCESS GROUP, SO `stop()` CAN KILL THE WHOLE TREE.
|
|
34264
|
+
//
|
|
34265
|
+
// OBSERVED: `shell: true` means the pid we track is `/bin/sh`, and the dev server
|
|
34266
|
+
// the user actually started (`next dev`, `vite`) is that shell's CHILD. Signalling
|
|
34267
|
+
// the tracked pid alone killed the shell and left the server running — holding its
|
|
34268
|
+
// port, still writing into the project directory, and now unreachable from Supbuddy
|
|
34269
|
+
// because the only handle to it was just discarded. Worse, the survivor keeps the
|
|
34270
|
+
// stdio pipes open, so Node never emits 'close' and `stop()` never resolves: the
|
|
34271
|
+
// caller's cleanup hangs behind a promise that will not settle.
|
|
34272
|
+
//
|
|
34273
|
+
// `detached` makes the shell a process-group LEADER (pgid === pid), which is what
|
|
34274
|
+
// makes `kill(-pid)` in `signalTree` mean "this command and everything it started"
|
|
34275
|
+
// rather than "some group that happens to share this number".
|
|
34276
|
+
//
|
|
34277
|
+
// Windows has no process groups to signal, and `detached` there would pop a console
|
|
34278
|
+
// window for a GUI-spawned child, so it stays off — `signalTree` documents what
|
|
34279
|
+
// that costs. The trade-off on POSIX: children no longer share the daemon's group,
|
|
34280
|
+
// so a Ctrl-C in a terminal running `supbuddy daemon` in the FOREGROUND no longer
|
|
34281
|
+
// reaches them. Under launchd and under the GUI — every shipped configuration —
|
|
34282
|
+
// nothing sent them that signal anyway.
|
|
34283
|
+
detached: nodeProcess.platform !== "win32",
|
|
34284
|
+
// Sanitize so a stale inherited Supbuddy-owned SSL_CERT_FILE (replace-
|
|
34285
|
+
// semantics, honored by Node 26) never reaches the user's dev server.
|
|
34286
|
+
env: getSanitizedSpawnEnv({
|
|
34287
|
+
...nodeProcess.env,
|
|
34288
|
+
PATH: getAugmentedPath(),
|
|
34289
|
+
FORCE_COLOR: "1",
|
|
34290
|
+
...env
|
|
34291
|
+
})
|
|
34292
|
+
});
|
|
34293
|
+
if (!child.pid) {
|
|
34294
|
+
child.on("error", (error) => {
|
|
34295
|
+
console.error(`[ProcessRunner] Spawn failed for ${argv.join(" ")}: ${error.message}`);
|
|
34296
|
+
});
|
|
34297
|
+
throw new SpawnNoPidError(`Failed to start process: ${argv.join(" ")}`);
|
|
34298
|
+
}
|
|
34299
|
+
const pump = (stream2) => (data) => {
|
|
34300
|
+
const lines = data.toString().split("\n").filter((l) => l.trim());
|
|
34301
|
+
for (const line of lines) {
|
|
34302
|
+
onLine(line, stream2);
|
|
34303
|
+
}
|
|
34304
|
+
};
|
|
34305
|
+
child.stdout?.on("data", pump("stdout"));
|
|
34306
|
+
child.stderr?.on("data", pump("stderr"));
|
|
34307
|
+
const pid = child.pid;
|
|
34308
|
+
let settled = false;
|
|
34309
|
+
const waiters = [];
|
|
34310
|
+
child.on("close", (code, signal) => {
|
|
34311
|
+
settled = true;
|
|
34312
|
+
onExit(code, signal);
|
|
34313
|
+
for (const waiter of waiters.splice(0)) waiter.resolve();
|
|
34314
|
+
});
|
|
34315
|
+
child.on("error", (error) => {
|
|
34316
|
+
settled = true;
|
|
34317
|
+
onError?.(error);
|
|
34318
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
34319
|
+
});
|
|
34320
|
+
const stop = () => new Promise((resolve, reject) => {
|
|
34321
|
+
if (settled) {
|
|
34322
|
+
resolve();
|
|
34323
|
+
return;
|
|
34324
|
+
}
|
|
34325
|
+
signalTree(child, pid, "SIGTERM");
|
|
34326
|
+
const killTimeout = setTimeout(() => {
|
|
34327
|
+
if (!settled) {
|
|
34328
|
+
console.log(`[ProcessRunner] Force killing: ${argv.join(" ")}`);
|
|
34329
|
+
signalTree(child, pid, "SIGKILL");
|
|
34330
|
+
}
|
|
34331
|
+
}, STOP_GRACE_MS);
|
|
34332
|
+
waiters.push({
|
|
34333
|
+
resolve: () => {
|
|
34334
|
+
clearTimeout(killTimeout);
|
|
34335
|
+
resolve();
|
|
34336
|
+
},
|
|
34337
|
+
reject: (error) => {
|
|
34338
|
+
clearTimeout(killTimeout);
|
|
34339
|
+
reject(error);
|
|
34340
|
+
}
|
|
34341
|
+
});
|
|
34342
|
+
});
|
|
34343
|
+
return { pid, stop };
|
|
34344
|
+
}
|
|
34345
|
+
const runningScripts = /* @__PURE__ */ new Map();
|
|
34346
|
+
const MAX_LOG_LINES = 1e3;
|
|
34347
|
+
async function detectPackageManager(projectPath) {
|
|
34348
|
+
const { nodeReadinessIo: nodeReadinessIo2 } = await __vitePreload(async () => {
|
|
34349
|
+
const { nodeReadinessIo: nodeReadinessIo3 } = await Promise.resolve().then(() => io$1);
|
|
34350
|
+
return { nodeReadinessIo: nodeReadinessIo3 };
|
|
34351
|
+
}, false ? __VITE_PRELOAD__ : void 0);
|
|
34352
|
+
const detected = await detectPackageManagerAt(projectPath, nodeReadinessIo2());
|
|
34353
|
+
return detected?.manager ?? null;
|
|
34354
|
+
}
|
|
34094
34355
|
async function scanProjectScripts(projectPath) {
|
|
34095
34356
|
try {
|
|
34096
34357
|
const pkgPath = path__namespace.join(projectPath, "package.json");
|
|
34097
34358
|
const pkgContent = await fsSync.promises.readFile(pkgPath, "utf-8");
|
|
34098
34359
|
const pkg = JSON.parse(pkgContent);
|
|
34099
34360
|
const scripts = pkg.scripts || {};
|
|
34100
|
-
|
|
34361
|
+
let packageManager = null;
|
|
34362
|
+
try {
|
|
34363
|
+
packageManager = await detectPackageManager(projectPath);
|
|
34364
|
+
} catch (error) {
|
|
34365
|
+
console.error(
|
|
34366
|
+
`[ScriptManager] Could not determine the package manager for ${projectPath}:`,
|
|
34367
|
+
error
|
|
34368
|
+
);
|
|
34369
|
+
}
|
|
34101
34370
|
return {
|
|
34102
34371
|
scripts: Object.entries(scripts).map(([name2, command]) => ({
|
|
34103
34372
|
name: name2,
|
|
@@ -34107,7 +34376,12 @@ async function scanProjectScripts(projectPath) {
|
|
|
34107
34376
|
};
|
|
34108
34377
|
} catch (error) {
|
|
34109
34378
|
console.error(`[ScriptManager] Failed to scan scripts:`, error);
|
|
34110
|
-
return { scripts: [], packageManager:
|
|
34379
|
+
return { scripts: [], packageManager: null };
|
|
34380
|
+
}
|
|
34381
|
+
}
|
|
34382
|
+
function deregister(key, spawned) {
|
|
34383
|
+
if (runningScripts.get(key)?.spawned === spawned) {
|
|
34384
|
+
runningScripts.delete(key);
|
|
34111
34385
|
}
|
|
34112
34386
|
}
|
|
34113
34387
|
async function startScript(projectId, projectPath, scriptName, command, packageManager, io2) {
|
|
@@ -34140,23 +34414,75 @@ async function startScript(projectId, projectPath, scriptName, command, packageM
|
|
|
34140
34414
|
throw new Error(errorMsg);
|
|
34141
34415
|
}
|
|
34142
34416
|
return new Promise((resolve, reject) => {
|
|
34143
|
-
|
|
34417
|
+
const logs = [];
|
|
34418
|
+
const pushLine = (line) => {
|
|
34419
|
+
logs.push(line);
|
|
34420
|
+
if (logs.length > MAX_LOG_LINES) {
|
|
34421
|
+
logs.shift();
|
|
34422
|
+
}
|
|
34423
|
+
};
|
|
34424
|
+
let spawned;
|
|
34144
34425
|
try {
|
|
34145
|
-
|
|
34426
|
+
spawned = spawnProjectCommand({
|
|
34146
34427
|
cwd: projectPath,
|
|
34147
|
-
|
|
34148
|
-
|
|
34149
|
-
//
|
|
34150
|
-
//
|
|
34151
|
-
|
|
34428
|
+
// No path here — `shell: true` re-parses this through /bin/sh, which is why
|
|
34429
|
+
// `projectPath` travels in `cwd`. `scriptName` is a package.json key rather
|
|
34430
|
+
// than a literal; see the rule in `process-runner.ts` for why that one is
|
|
34431
|
+
// tolerated and a directory is not.
|
|
34432
|
+
argv: [packageManager, "run", scriptName],
|
|
34433
|
+
onLine: (line, stream2) => {
|
|
34434
|
+
pushLine(line);
|
|
34435
|
+
io2.emit("script:log", {
|
|
34436
|
+
projectId,
|
|
34437
|
+
scriptName,
|
|
34438
|
+
line,
|
|
34439
|
+
type: stream2,
|
|
34440
|
+
timestamp: Date.now()
|
|
34441
|
+
});
|
|
34442
|
+
},
|
|
34443
|
+
onExit: (code) => {
|
|
34444
|
+
const lastLogs = logs.slice(-5).join("\n");
|
|
34445
|
+
console.log(`[ScriptManager] Script "${scriptName}" exited with code ${code}. Last output:
|
|
34446
|
+
${lastLogs}`);
|
|
34447
|
+
if (code === 127) {
|
|
34448
|
+
const errorMsg = `"${packageManager}" command not found. Please install ${packageManager} and try again.`;
|
|
34449
|
+
io2.emit("script:log", {
|
|
34450
|
+
projectId,
|
|
34451
|
+
scriptName,
|
|
34452
|
+
line: `Error: ${errorMsg}`,
|
|
34453
|
+
type: "stderr",
|
|
34454
|
+
timestamp: Date.now()
|
|
34455
|
+
});
|
|
34456
|
+
}
|
|
34457
|
+
const status = code === 0 ? "stopped" : "crashed";
|
|
34458
|
+
io2.emit("script:status", {
|
|
34459
|
+
projectId,
|
|
34460
|
+
scriptName,
|
|
34461
|
+
status,
|
|
34462
|
+
exitCode: code,
|
|
34463
|
+
...code === 127 ? { error: `"${packageManager}" not found. Please install it.` } : {}
|
|
34464
|
+
});
|
|
34465
|
+
deregister(key, spawned);
|
|
34466
|
+
},
|
|
34467
|
+
onError: (error) => {
|
|
34468
|
+
console.error(`[ScriptManager] Script "${scriptName}" error:`, error);
|
|
34469
|
+
io2.emit("script:status", {
|
|
34470
|
+
projectId,
|
|
34471
|
+
scriptName,
|
|
34472
|
+
status: "crashed",
|
|
34473
|
+
error: error.message
|
|
34474
|
+
});
|
|
34475
|
+
deregister(key, spawned);
|
|
34476
|
+
reject(error);
|
|
34477
|
+
}
|
|
34152
34478
|
});
|
|
34153
34479
|
} catch (err) {
|
|
34154
|
-
|
|
34155
|
-
|
|
34156
|
-
|
|
34157
|
-
|
|
34158
|
-
|
|
34159
|
-
|
|
34480
|
+
if (err instanceof SpawnNoPidError) {
|
|
34481
|
+
reject(new Error("Failed to start script process"));
|
|
34482
|
+
} else {
|
|
34483
|
+
console.error(`[ScriptManager] Failed to spawn: ${err.message}`);
|
|
34484
|
+
reject(err);
|
|
34485
|
+
}
|
|
34160
34486
|
return;
|
|
34161
34487
|
}
|
|
34162
34488
|
const runningScript = {
|
|
@@ -34164,89 +34490,22 @@ async function startScript(projectId, projectPath, scriptName, command, packageM
|
|
|
34164
34490
|
scriptName,
|
|
34165
34491
|
command,
|
|
34166
34492
|
packageManager,
|
|
34167
|
-
|
|
34168
|
-
pid:
|
|
34493
|
+
spawned,
|
|
34494
|
+
pid: spawned.pid,
|
|
34169
34495
|
startTime: Date.now(),
|
|
34170
34496
|
restartCount: 0,
|
|
34171
|
-
logs
|
|
34497
|
+
logs
|
|
34172
34498
|
};
|
|
34173
34499
|
runningScripts.set(key, runningScript);
|
|
34174
34500
|
io2.emit("script:status", {
|
|
34175
34501
|
projectId,
|
|
34176
34502
|
scriptName,
|
|
34177
34503
|
status: "running",
|
|
34178
|
-
pid:
|
|
34504
|
+
pid: spawned.pid,
|
|
34179
34505
|
startTime: runningScript.startTime,
|
|
34180
34506
|
uptime: 0,
|
|
34181
34507
|
restartCount: 0
|
|
34182
34508
|
});
|
|
34183
|
-
child.stdout?.on("data", (data) => {
|
|
34184
|
-
const lines = data.toString().split("\n").filter((l) => l.trim());
|
|
34185
|
-
for (const line of lines) {
|
|
34186
|
-
runningScript.logs.push(line);
|
|
34187
|
-
if (runningScript.logs.length > MAX_LOG_LINES) {
|
|
34188
|
-
runningScript.logs.shift();
|
|
34189
|
-
}
|
|
34190
|
-
io2.emit("script:log", {
|
|
34191
|
-
projectId,
|
|
34192
|
-
scriptName,
|
|
34193
|
-
line,
|
|
34194
|
-
type: "stdout",
|
|
34195
|
-
timestamp: Date.now()
|
|
34196
|
-
});
|
|
34197
|
-
}
|
|
34198
|
-
});
|
|
34199
|
-
child.stderr?.on("data", (data) => {
|
|
34200
|
-
const lines = data.toString().split("\n").filter((l) => l.trim());
|
|
34201
|
-
for (const line of lines) {
|
|
34202
|
-
runningScript.logs.push(line);
|
|
34203
|
-
if (runningScript.logs.length > MAX_LOG_LINES) {
|
|
34204
|
-
runningScript.logs.shift();
|
|
34205
|
-
}
|
|
34206
|
-
io2.emit("script:log", {
|
|
34207
|
-
projectId,
|
|
34208
|
-
scriptName,
|
|
34209
|
-
line,
|
|
34210
|
-
type: "stderr",
|
|
34211
|
-
timestamp: Date.now()
|
|
34212
|
-
});
|
|
34213
|
-
}
|
|
34214
|
-
});
|
|
34215
|
-
child.on("close", (code) => {
|
|
34216
|
-
const lastLogs = runningScript.logs.slice(-5).join("\n");
|
|
34217
|
-
console.log(`[ScriptManager] Script "${scriptName}" exited with code ${code}. Last output:
|
|
34218
|
-
${lastLogs}`);
|
|
34219
|
-
if (code === 127) {
|
|
34220
|
-
const errorMsg = `"${packageManager}" command not found. Please install ${packageManager} and try again.`;
|
|
34221
|
-
io2.emit("script:log", {
|
|
34222
|
-
projectId,
|
|
34223
|
-
scriptName,
|
|
34224
|
-
line: `Error: ${errorMsg}`,
|
|
34225
|
-
type: "stderr",
|
|
34226
|
-
timestamp: Date.now()
|
|
34227
|
-
});
|
|
34228
|
-
}
|
|
34229
|
-
const status = code === 0 ? "stopped" : "crashed";
|
|
34230
|
-
io2.emit("script:status", {
|
|
34231
|
-
projectId,
|
|
34232
|
-
scriptName,
|
|
34233
|
-
status,
|
|
34234
|
-
exitCode: code,
|
|
34235
|
-
...code === 127 ? { error: `"${packageManager}" not found. Please install it.` } : {}
|
|
34236
|
-
});
|
|
34237
|
-
runningScripts.delete(key);
|
|
34238
|
-
});
|
|
34239
|
-
child.on("error", (error) => {
|
|
34240
|
-
console.error(`[ScriptManager] Script "${scriptName}" error:`, error);
|
|
34241
|
-
io2.emit("script:status", {
|
|
34242
|
-
projectId,
|
|
34243
|
-
scriptName,
|
|
34244
|
-
status: "crashed",
|
|
34245
|
-
error: error.message
|
|
34246
|
-
});
|
|
34247
|
-
runningScripts.delete(key);
|
|
34248
|
-
reject(error);
|
|
34249
|
-
});
|
|
34250
34509
|
resolve();
|
|
34251
34510
|
});
|
|
34252
34511
|
}
|
|
@@ -34257,30 +34516,12 @@ async function stopScript(projectId, scriptName, io2) {
|
|
|
34257
34516
|
throw new Error(`Script "${scriptName}" is not running`);
|
|
34258
34517
|
}
|
|
34259
34518
|
console.log(`[ScriptManager] Stopping script: ${scriptName}`);
|
|
34260
|
-
|
|
34261
|
-
|
|
34262
|
-
|
|
34263
|
-
|
|
34264
|
-
|
|
34265
|
-
|
|
34266
|
-
child.kill("SIGKILL");
|
|
34267
|
-
}
|
|
34268
|
-
}, 5e3);
|
|
34269
|
-
child.once("close", () => {
|
|
34270
|
-
clearTimeout(killTimeout);
|
|
34271
|
-
runningScripts.delete(key);
|
|
34272
|
-
io2.emit("script:status", {
|
|
34273
|
-
projectId,
|
|
34274
|
-
scriptName,
|
|
34275
|
-
status: "stopped"
|
|
34276
|
-
});
|
|
34277
|
-
resolve();
|
|
34278
|
-
});
|
|
34279
|
-
child.once("error", (error) => {
|
|
34280
|
-
clearTimeout(killTimeout);
|
|
34281
|
-
runningScripts.delete(key);
|
|
34282
|
-
reject(error);
|
|
34283
|
-
});
|
|
34519
|
+
runningScripts.delete(key);
|
|
34520
|
+
await runningScript.spawned.stop();
|
|
34521
|
+
io2.emit("script:status", {
|
|
34522
|
+
projectId,
|
|
34523
|
+
scriptName,
|
|
34524
|
+
status: "stopped"
|
|
34284
34525
|
});
|
|
34285
34526
|
}
|
|
34286
34527
|
async function restartScript(projectId, projectPath, scriptName, command, packageManager, io2) {
|
|
@@ -34319,13 +34560,16 @@ function getScriptLogs(projectId, scriptName) {
|
|
|
34319
34560
|
}
|
|
34320
34561
|
async function stopAllProjectScripts(projectId, io2) {
|
|
34321
34562
|
const scriptsToStop = Array.from(runningScripts.values()).filter((s) => s.projectId === projectId);
|
|
34563
|
+
const stopped = [];
|
|
34322
34564
|
for (const script of scriptsToStop) {
|
|
34323
34565
|
try {
|
|
34324
34566
|
await stopScript(projectId, script.scriptName, io2);
|
|
34567
|
+
stopped.push(script.scriptName);
|
|
34325
34568
|
} catch (error) {
|
|
34326
34569
|
console.error(`[ScriptManager] Failed to stop script ${script.scriptName}:`, error);
|
|
34327
34570
|
}
|
|
34328
34571
|
}
|
|
34572
|
+
return stopped;
|
|
34329
34573
|
}
|
|
34330
34574
|
function getAllRunningScripts() {
|
|
34331
34575
|
return new Map(runningScripts);
|
|
@@ -34343,6 +34587,104 @@ const scriptManager = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defin
|
|
|
34343
34587
|
stopAllProjectScripts,
|
|
34344
34588
|
stopScript
|
|
34345
34589
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
34590
|
+
function toPosix(input) {
|
|
34591
|
+
return input.replace(/\\/g, "/");
|
|
34592
|
+
}
|
|
34593
|
+
function isAbsolutePath(input) {
|
|
34594
|
+
return path.posix.isAbsolute(input) || path.win32.isAbsolute(input);
|
|
34595
|
+
}
|
|
34596
|
+
function rooted(input) {
|
|
34597
|
+
return /^[A-Za-z]:/.test(input) ? `/${input}` : input;
|
|
34598
|
+
}
|
|
34599
|
+
function normalizeDirPath(input) {
|
|
34600
|
+
const normalized = path.posix.normalize(toPosix(input));
|
|
34601
|
+
return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
34602
|
+
}
|
|
34603
|
+
function projectRelative(projectPath, target) {
|
|
34604
|
+
if (!target) return "";
|
|
34605
|
+
const posixTarget = toPosix(target);
|
|
34606
|
+
const rel = isAbsolutePath(posixTarget) ? path.posix.relative(rooted(toPosix(projectPath)), rooted(posixTarget)) : posixTarget;
|
|
34607
|
+
const normalized = path.posix.normalize(rel || ".");
|
|
34608
|
+
const trimmed = normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
34609
|
+
return trimmed === "." ? "" : trimmed;
|
|
34610
|
+
}
|
|
34611
|
+
function rootLabel(relPath) {
|
|
34612
|
+
return relPath === "" ? "." : relPath;
|
|
34613
|
+
}
|
|
34614
|
+
function rootPhrase(relPath) {
|
|
34615
|
+
return relPath === "" ? "the project root" : relPath;
|
|
34616
|
+
}
|
|
34617
|
+
function isMissing(err) {
|
|
34618
|
+
const code = err?.code;
|
|
34619
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
34620
|
+
}
|
|
34621
|
+
function binaryCandidates(bin) {
|
|
34622
|
+
if (os$1.platform() !== "win32") return [bin];
|
|
34623
|
+
const pathext = (nodeProcess.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
34624
|
+
return [bin, ...pathext.map((ext) => `${bin}${ext.toLowerCase()}`)];
|
|
34625
|
+
}
|
|
34626
|
+
function nodeReadinessIo() {
|
|
34627
|
+
return {
|
|
34628
|
+
async readFile(filePath) {
|
|
34629
|
+
try {
|
|
34630
|
+
return await fs.readFile(filePath, "utf-8");
|
|
34631
|
+
} catch (err) {
|
|
34632
|
+
if (isMissing(err)) return null;
|
|
34633
|
+
throw err;
|
|
34634
|
+
}
|
|
34635
|
+
},
|
|
34636
|
+
async readDir(dirPath) {
|
|
34637
|
+
try {
|
|
34638
|
+
return await fs.readdir(dirPath);
|
|
34639
|
+
} catch (err) {
|
|
34640
|
+
if (isMissing(err)) return [];
|
|
34641
|
+
throw err;
|
|
34642
|
+
}
|
|
34643
|
+
},
|
|
34644
|
+
async statMtimeMs(filePath) {
|
|
34645
|
+
try {
|
|
34646
|
+
const stat = await fs.stat(filePath);
|
|
34647
|
+
return stat.mtimeMs;
|
|
34648
|
+
} catch (err) {
|
|
34649
|
+
if (isMissing(err)) return null;
|
|
34650
|
+
throw err;
|
|
34651
|
+
}
|
|
34652
|
+
},
|
|
34653
|
+
/**
|
|
34654
|
+
* `fs.realpath` resolves every link in the path, which is what lets the tree walk
|
|
34655
|
+
* recognize `a/b -> a` as `a`. A path that does not resolve is not a failure here —
|
|
34656
|
+
* a broken symlink is ENOENT and a dangling one is common — so it answers null and
|
|
34657
|
+
* the caller falls back to the literal path. Every other errno still escapes, same
|
|
34658
|
+
* as the readers above: "I could not look" is never "there is nothing there".
|
|
34659
|
+
*/
|
|
34660
|
+
async realPath(target) {
|
|
34661
|
+
try {
|
|
34662
|
+
return await fs.realpath(target);
|
|
34663
|
+
} catch (err) {
|
|
34664
|
+
if (isMissing(err)) return null;
|
|
34665
|
+
throw err;
|
|
34666
|
+
}
|
|
34667
|
+
},
|
|
34668
|
+
async hasBinary(bin) {
|
|
34669
|
+
const entries = getAugmentedPath().split(path.delimiter).filter(Boolean);
|
|
34670
|
+
const candidates = binaryCandidates(bin);
|
|
34671
|
+
for (const entry of entries) {
|
|
34672
|
+
for (const candidate of candidates) {
|
|
34673
|
+
try {
|
|
34674
|
+
await fs.access(path.join(entry, candidate), fsSync.constants.X_OK);
|
|
34675
|
+
return true;
|
|
34676
|
+
} catch {
|
|
34677
|
+
}
|
|
34678
|
+
}
|
|
34679
|
+
}
|
|
34680
|
+
return false;
|
|
34681
|
+
}
|
|
34682
|
+
};
|
|
34683
|
+
}
|
|
34684
|
+
const io$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
34685
|
+
__proto__: null,
|
|
34686
|
+
nodeReadinessIo
|
|
34687
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
34346
34688
|
const PUBLISHED_HOST_PORT_RE = /(?:0\.0\.0\.0|127\.0\.0\.1|\[::\]):(\d+)->/;
|
|
34347
34689
|
const STOCK_SUPABASE_BLOCK_START = 54320;
|
|
34348
34690
|
const STOCK_SUPABASE_BLOCK_END = 54329;
|
|
@@ -34366,7 +34708,7 @@ async function scanProject(projectPath, options = {}) {
|
|
|
34366
34708
|
}
|
|
34367
34709
|
result.isTurboRepo = await isTurboRepo(projectPath) || await isWorkspaceMonorepo(projectPath);
|
|
34368
34710
|
const scriptData = await scanProjectScripts(projectPath);
|
|
34369
|
-
result.packageManager = scriptData.packageManager;
|
|
34711
|
+
result.packageManager = scriptData.packageManager ?? void 0;
|
|
34370
34712
|
const autoBookmarkPatterns = [
|
|
34371
34713
|
"dev",
|
|
34372
34714
|
"develop",
|
|
@@ -34536,16 +34878,18 @@ async function detectAppType(appPath) {
|
|
|
34536
34878
|
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
34537
34879
|
const scripts = packageJson.scripts || {};
|
|
34538
34880
|
const WEB_FRAMEWORKS = {
|
|
34881
|
+
// Meta-frameworks BEFORE the bundlers they depend on.
|
|
34882
|
+
"@sveltejs/kit": "sveltekit",
|
|
34883
|
+
"astro": "astro",
|
|
34884
|
+
"nuxt": "nuxt",
|
|
34885
|
+
"nuxt3": "nuxt",
|
|
34886
|
+
"@remix-run/dev": "remix",
|
|
34887
|
+
"@remix-run/serve": "remix",
|
|
34539
34888
|
"next": "next",
|
|
34889
|
+
// Bundlers last, so they only claim an app no meta-framework claimed.
|
|
34540
34890
|
"vite": "vite",
|
|
34541
|
-
"@remix-run/dev": "node",
|
|
34542
|
-
"@remix-run/serve": "node",
|
|
34543
|
-
"astro": "node",
|
|
34544
|
-
"nuxt": "node",
|
|
34545
|
-
"nuxt3": "node",
|
|
34546
|
-
"@sveltejs/kit": "node",
|
|
34547
34891
|
"@angular/core": "node",
|
|
34548
|
-
// Backend frameworks
|
|
34892
|
+
// Backend frameworks (order among themselves is irrelevant — none subsumes another)
|
|
34549
34893
|
"express": "node",
|
|
34550
34894
|
"fastify": "node",
|
|
34551
34895
|
"koa": "node",
|
|
@@ -34559,13 +34903,6 @@ async function detectAppType(appPath) {
|
|
|
34559
34903
|
let type2 = "unknown";
|
|
34560
34904
|
let port;
|
|
34561
34905
|
let frameworkDep;
|
|
34562
|
-
for (const [dep, depType] of Object.entries(WEB_FRAMEWORKS)) {
|
|
34563
|
-
if (deps[dep]) {
|
|
34564
|
-
type2 = depType;
|
|
34565
|
-
frameworkDep = dep;
|
|
34566
|
-
break;
|
|
34567
|
-
}
|
|
34568
|
-
}
|
|
34569
34906
|
const FRAMEWORK_SCRIPT_PATTERNS = {
|
|
34570
34907
|
"next": /\bnext\s+(dev|start)\b/,
|
|
34571
34908
|
"vite": /\bvite\b(?!\s*(build|preview))/,
|
|
@@ -34587,9 +34924,25 @@ async function detectAppType(appPath) {
|
|
|
34587
34924
|
"tinyhttp": /\b(node|tsx|ts-node|nodemon)\b/
|
|
34588
34925
|
};
|
|
34589
34926
|
const devStartCombined = [scripts.dev, scripts.start].filter(Boolean).join(" ");
|
|
34590
|
-
const
|
|
34927
|
+
const scriptConfirmsFramework = (dep, depType) => {
|
|
34928
|
+
if (!devStartCombined) return false;
|
|
34929
|
+
if (FRAMEWORK_SCRIPT_PATTERNS[dep]?.test(devStartCombined)) return true;
|
|
34930
|
+
const runsViteUnderneath = (depType === "sveltekit" || depType === "remix") && !!deps["vite"];
|
|
34931
|
+
return runsViteUnderneath && FRAMEWORK_SCRIPT_PATTERNS["vite"].test(devStartCombined);
|
|
34932
|
+
};
|
|
34933
|
+
const frameworkCandidates = Object.entries(WEB_FRAMEWORKS).filter(([dep]) => !!deps[dep]);
|
|
34934
|
+
const confirmedCandidate = frameworkCandidates.find(
|
|
34935
|
+
([dep, depType]) => scriptConfirmsFramework(dep, depType)
|
|
34936
|
+
);
|
|
34937
|
+
const selectedCandidate = confirmedCandidate ?? frameworkCandidates[0];
|
|
34938
|
+
if (selectedCandidate) {
|
|
34939
|
+
frameworkDep = selectedCandidate[0];
|
|
34940
|
+
type2 = selectedCandidate[1];
|
|
34941
|
+
}
|
|
34942
|
+
const runsViteDevServer = type2 === "vite" || (type2 === "sveltekit" || type2 === "remix") && !!deps["vite"];
|
|
34943
|
+
const hasWebFramework = !!(frameworkDep && devStartCombined && confirmedCandidate);
|
|
34591
34944
|
if (frameworkDep) {
|
|
34592
|
-
port = await detectAppPort(appPath, frameworkDep, scripts);
|
|
34945
|
+
port = await detectAppPort(appPath, frameworkDep, scripts, runsViteDevServer);
|
|
34593
34946
|
}
|
|
34594
34947
|
if (!hasWebFramework) {
|
|
34595
34948
|
return null;
|
|
@@ -34610,7 +34963,7 @@ async function detectAppType(appPath) {
|
|
|
34610
34963
|
app.allowedOrigins = parsed.origins;
|
|
34611
34964
|
}
|
|
34612
34965
|
}
|
|
34613
|
-
if (
|
|
34966
|
+
if (runsViteDevServer) {
|
|
34614
34967
|
const parsed = await readViteAllowedHosts(appPath);
|
|
34615
34968
|
if (parsed) {
|
|
34616
34969
|
app.viteConfigPath = parsed.configPath;
|
|
@@ -34618,19 +34971,10 @@ async function detectAppType(appPath) {
|
|
|
34618
34971
|
}
|
|
34619
34972
|
}
|
|
34620
34973
|
try {
|
|
34621
|
-
await
|
|
34622
|
-
app.packageManager =
|
|
34623
|
-
} catch {
|
|
34624
|
-
|
|
34625
|
-
await fs.access(path.join(appPath, "pnpm-lock.yaml"));
|
|
34626
|
-
app.packageManager = "pnpm";
|
|
34627
|
-
} catch {
|
|
34628
|
-
try {
|
|
34629
|
-
await fs.access(path.join(appPath, "package-lock.json"));
|
|
34630
|
-
app.packageManager = "npm";
|
|
34631
|
-
} catch {
|
|
34632
|
-
}
|
|
34633
|
-
}
|
|
34974
|
+
const detected = await detectPackageManagerAt(appPath, nodeReadinessIo());
|
|
34975
|
+
if (detected) app.packageManager = detected.manager;
|
|
34976
|
+
} catch (err) {
|
|
34977
|
+
console.warn(`[ProjectScanner] Could not determine package manager for ${appPath}:`, err);
|
|
34634
34978
|
}
|
|
34635
34979
|
return app;
|
|
34636
34980
|
} catch {
|
|
@@ -34807,7 +35151,7 @@ async function readPortFromConfig(appPath, frameworkDep) {
|
|
|
34807
35151
|
return void 0;
|
|
34808
35152
|
}
|
|
34809
35153
|
}
|
|
34810
|
-
async function detectAppPort(appPath, frameworkDep, scripts) {
|
|
35154
|
+
async function detectAppPort(appPath, frameworkDep, scripts, runsViteDevServer = false) {
|
|
34811
35155
|
const devStart = [scripts.dev, scripts.start].filter(Boolean).join(" ");
|
|
34812
35156
|
if (devStart) {
|
|
34813
35157
|
const cliMatch = devStart.match(/(?:^|\s)(?:-p|--port)\s+(\d+)\b/);
|
|
@@ -34817,6 +35161,11 @@ async function detectAppPort(appPath, frameworkDep, scripts) {
|
|
|
34817
35161
|
}
|
|
34818
35162
|
const configPort = await readPortFromConfig(appPath, frameworkDep);
|
|
34819
35163
|
if (configPort != null) return configPort;
|
|
35164
|
+
if (runsViteDevServer) {
|
|
35165
|
+
const vitePort = await readPortFromConfig(appPath, "vite");
|
|
35166
|
+
if (vitePort != null) return vitePort;
|
|
35167
|
+
}
|
|
35168
|
+
if (runsViteDevServer) return FRAMEWORK_DEFAULT_PORTS["vite"];
|
|
34820
35169
|
return FRAMEWORK_DEFAULT_PORTS[frameworkDep];
|
|
34821
35170
|
}
|
|
34822
35171
|
async function readNextAllowedOrigins(appPath) {
|
|
@@ -37320,14 +37669,14 @@ const require$$6 = /* @__PURE__ */ getAugmentedNamespace(ipCodec);
|
|
|
37320
37669
|
return len;
|
|
37321
37670
|
};
|
|
37322
37671
|
const rnsec = exports2.nsec = {};
|
|
37323
|
-
rnsec.encode = function(
|
|
37324
|
-
if (!buf) buf = Buffer2.alloc(rnsec.encodingLength(
|
|
37672
|
+
rnsec.encode = function(record2, buf, offset) {
|
|
37673
|
+
if (!buf) buf = Buffer2.alloc(rnsec.encodingLength(record2));
|
|
37325
37674
|
if (!offset) offset = 0;
|
|
37326
37675
|
const oldOffset = offset;
|
|
37327
37676
|
offset += 2;
|
|
37328
|
-
name2.encode(
|
|
37677
|
+
name2.encode(record2.nextDomain, buf, offset);
|
|
37329
37678
|
offset += name2.encode.bytes;
|
|
37330
|
-
typebitmap.encode(
|
|
37679
|
+
typebitmap.encode(record2.rrtypes, buf, offset);
|
|
37331
37680
|
offset += typebitmap.encode.bytes;
|
|
37332
37681
|
rnsec.encode.bytes = offset - oldOffset;
|
|
37333
37682
|
buf.writeUInt16BE(rnsec.encode.bytes - 2, oldOffset);
|
|
@@ -37337,39 +37686,39 @@ const require$$6 = /* @__PURE__ */ getAugmentedNamespace(ipCodec);
|
|
|
37337
37686
|
rnsec.decode = function(buf, offset) {
|
|
37338
37687
|
if (!offset) offset = 0;
|
|
37339
37688
|
const oldOffset = offset;
|
|
37340
|
-
var
|
|
37689
|
+
var record2 = {};
|
|
37341
37690
|
var length2 = buf.readUInt16BE(offset);
|
|
37342
37691
|
offset += 2;
|
|
37343
|
-
|
|
37692
|
+
record2.nextDomain = name2.decode(buf, offset);
|
|
37344
37693
|
offset += name2.decode.bytes;
|
|
37345
|
-
|
|
37694
|
+
record2.rrtypes = typebitmap.decode(buf, offset, length2 - (offset - oldOffset));
|
|
37346
37695
|
offset += typebitmap.decode.bytes;
|
|
37347
37696
|
rnsec.decode.bytes = offset - oldOffset;
|
|
37348
|
-
return
|
|
37697
|
+
return record2;
|
|
37349
37698
|
};
|
|
37350
37699
|
rnsec.decode.bytes = 0;
|
|
37351
|
-
rnsec.encodingLength = function(
|
|
37352
|
-
return 2 + name2.encodingLength(
|
|
37700
|
+
rnsec.encodingLength = function(record2) {
|
|
37701
|
+
return 2 + name2.encodingLength(record2.nextDomain) + typebitmap.encodingLength(record2.rrtypes);
|
|
37353
37702
|
};
|
|
37354
37703
|
const rnsec3 = exports2.nsec3 = {};
|
|
37355
|
-
rnsec3.encode = function(
|
|
37356
|
-
if (!buf) buf = Buffer2.alloc(rnsec3.encodingLength(
|
|
37704
|
+
rnsec3.encode = function(record2, buf, offset) {
|
|
37705
|
+
if (!buf) buf = Buffer2.alloc(rnsec3.encodingLength(record2));
|
|
37357
37706
|
if (!offset) offset = 0;
|
|
37358
37707
|
const oldOffset = offset;
|
|
37359
|
-
const salt =
|
|
37708
|
+
const salt = record2.salt;
|
|
37360
37709
|
if (!Buffer2.isBuffer(salt)) {
|
|
37361
37710
|
throw new Error("salt must be a Buffer");
|
|
37362
37711
|
}
|
|
37363
|
-
const nextDomain =
|
|
37712
|
+
const nextDomain = record2.nextDomain;
|
|
37364
37713
|
if (!Buffer2.isBuffer(nextDomain)) {
|
|
37365
37714
|
throw new Error("nextDomain must be a Buffer");
|
|
37366
37715
|
}
|
|
37367
37716
|
offset += 2;
|
|
37368
|
-
buf.writeUInt8(
|
|
37717
|
+
buf.writeUInt8(record2.algorithm, offset);
|
|
37369
37718
|
offset += 1;
|
|
37370
|
-
buf.writeUInt8(
|
|
37719
|
+
buf.writeUInt8(record2.flags, offset);
|
|
37371
37720
|
offset += 1;
|
|
37372
|
-
buf.writeUInt16BE(
|
|
37721
|
+
buf.writeUInt16BE(record2.iterations, offset);
|
|
37373
37722
|
offset += 2;
|
|
37374
37723
|
buf.writeUInt8(salt.length, offset);
|
|
37375
37724
|
offset += 1;
|
|
@@ -37379,7 +37728,7 @@ const require$$6 = /* @__PURE__ */ getAugmentedNamespace(ipCodec);
|
|
|
37379
37728
|
offset += 1;
|
|
37380
37729
|
nextDomain.copy(buf, offset, 0, nextDomain.length);
|
|
37381
37730
|
offset += nextDomain.length;
|
|
37382
|
-
typebitmap.encode(
|
|
37731
|
+
typebitmap.encode(record2.rrtypes, buf, offset);
|
|
37383
37732
|
offset += typebitmap.encode.bytes;
|
|
37384
37733
|
rnsec3.encode.bytes = offset - oldOffset;
|
|
37385
37734
|
buf.writeUInt16BE(rnsec3.encode.bytes - 2, oldOffset);
|
|
@@ -37389,31 +37738,31 @@ const require$$6 = /* @__PURE__ */ getAugmentedNamespace(ipCodec);
|
|
|
37389
37738
|
rnsec3.decode = function(buf, offset) {
|
|
37390
37739
|
if (!offset) offset = 0;
|
|
37391
37740
|
const oldOffset = offset;
|
|
37392
|
-
var
|
|
37741
|
+
var record2 = {};
|
|
37393
37742
|
var length2 = buf.readUInt16BE(offset);
|
|
37394
37743
|
offset += 2;
|
|
37395
|
-
|
|
37744
|
+
record2.algorithm = buf.readUInt8(offset);
|
|
37396
37745
|
offset += 1;
|
|
37397
|
-
|
|
37746
|
+
record2.flags = buf.readUInt8(offset);
|
|
37398
37747
|
offset += 1;
|
|
37399
|
-
|
|
37748
|
+
record2.iterations = buf.readUInt16BE(offset);
|
|
37400
37749
|
offset += 2;
|
|
37401
37750
|
const saltLength = buf.readUInt8(offset);
|
|
37402
37751
|
offset += 1;
|
|
37403
|
-
|
|
37752
|
+
record2.salt = buf.slice(offset, offset + saltLength);
|
|
37404
37753
|
offset += saltLength;
|
|
37405
37754
|
const hashLength = buf.readUInt8(offset);
|
|
37406
37755
|
offset += 1;
|
|
37407
|
-
|
|
37756
|
+
record2.nextDomain = buf.slice(offset, offset + hashLength);
|
|
37408
37757
|
offset += hashLength;
|
|
37409
|
-
|
|
37758
|
+
record2.rrtypes = typebitmap.decode(buf, offset, length2 - (offset - oldOffset));
|
|
37410
37759
|
offset += typebitmap.decode.bytes;
|
|
37411
37760
|
rnsec3.decode.bytes = offset - oldOffset;
|
|
37412
|
-
return
|
|
37761
|
+
return record2;
|
|
37413
37762
|
};
|
|
37414
37763
|
rnsec3.decode.bytes = 0;
|
|
37415
|
-
rnsec3.encodingLength = function(
|
|
37416
|
-
return 8 +
|
|
37764
|
+
rnsec3.encodingLength = function(record2) {
|
|
37765
|
+
return 8 + record2.salt.length + record2.nextDomain.length + typebitmap.encodingLength(record2.rrtypes);
|
|
37417
37766
|
};
|
|
37418
37767
|
const rds = exports2.ds = {};
|
|
37419
37768
|
rds.encode = function(digest, buf, offset) {
|
|
@@ -37468,17 +37817,17 @@ const require$$6 = /* @__PURE__ */ getAugmentedNamespace(ipCodec);
|
|
|
37468
37817
|
return 32;
|
|
37469
37818
|
}
|
|
37470
37819
|
};
|
|
37471
|
-
rsshfp.encode = function encode2(
|
|
37472
|
-
if (!buf) buf = Buffer2.alloc(rsshfp.encodingLength(
|
|
37820
|
+
rsshfp.encode = function encode2(record2, buf, offset) {
|
|
37821
|
+
if (!buf) buf = Buffer2.alloc(rsshfp.encodingLength(record2));
|
|
37473
37822
|
if (!offset) offset = 0;
|
|
37474
37823
|
const oldOffset = offset;
|
|
37475
37824
|
offset += 2;
|
|
37476
|
-
buf[offset] =
|
|
37825
|
+
buf[offset] = record2.algorithm;
|
|
37477
37826
|
offset += 1;
|
|
37478
|
-
buf[offset] =
|
|
37827
|
+
buf[offset] = record2.hash;
|
|
37479
37828
|
offset += 1;
|
|
37480
|
-
const fingerprintBuf = Buffer2.from(
|
|
37481
|
-
if (fingerprintBuf.length !== rsshfp.getFingerprintLengthForHashType(
|
|
37829
|
+
const fingerprintBuf = Buffer2.from(record2.fingerprint.toUpperCase(), "hex");
|
|
37830
|
+
if (fingerprintBuf.length !== rsshfp.getFingerprintLengthForHashType(record2.hash)) {
|
|
37482
37831
|
throw new Error("Invalid fingerprint length");
|
|
37483
37832
|
}
|
|
37484
37833
|
fingerprintBuf.copy(buf, offset);
|
|
@@ -37491,21 +37840,21 @@ const require$$6 = /* @__PURE__ */ getAugmentedNamespace(ipCodec);
|
|
|
37491
37840
|
rsshfp.decode = function decode2(buf, offset) {
|
|
37492
37841
|
if (!offset) offset = 0;
|
|
37493
37842
|
const oldOffset = offset;
|
|
37494
|
-
const
|
|
37843
|
+
const record2 = {};
|
|
37495
37844
|
offset += 2;
|
|
37496
|
-
|
|
37845
|
+
record2.algorithm = buf[offset];
|
|
37497
37846
|
offset += 1;
|
|
37498
|
-
|
|
37847
|
+
record2.hash = buf[offset];
|
|
37499
37848
|
offset += 1;
|
|
37500
|
-
const fingerprintLength = rsshfp.getFingerprintLengthForHashType(
|
|
37501
|
-
|
|
37849
|
+
const fingerprintLength = rsshfp.getFingerprintLengthForHashType(record2.hash);
|
|
37850
|
+
record2.fingerprint = buf.slice(offset, offset + fingerprintLength).toString("hex").toUpperCase();
|
|
37502
37851
|
offset += fingerprintLength;
|
|
37503
37852
|
rsshfp.decode.bytes = offset - oldOffset;
|
|
37504
|
-
return
|
|
37853
|
+
return record2;
|
|
37505
37854
|
};
|
|
37506
37855
|
rsshfp.decode.bytes = 0;
|
|
37507
|
-
rsshfp.encodingLength = function(
|
|
37508
|
-
return 4 + Buffer2.from(
|
|
37856
|
+
rsshfp.encodingLength = function(record2) {
|
|
37857
|
+
return 4 + Buffer2.from(record2.fingerprint, "hex").byteLength;
|
|
37509
37858
|
};
|
|
37510
37859
|
const rnaptr = exports2.naptr = {};
|
|
37511
37860
|
rnaptr.encode = function(data, buf, offset) {
|
|
@@ -39177,9 +39526,9 @@ Packet$9.Resource.A = function(address) {
|
|
|
39177
39526
|
this.address = address;
|
|
39178
39527
|
return this;
|
|
39179
39528
|
};
|
|
39180
|
-
Packet$9.Resource.A.encode = function(
|
|
39529
|
+
Packet$9.Resource.A.encode = function(record2, writer2) {
|
|
39181
39530
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39182
|
-
|
|
39531
|
+
record2.address.split(".").forEach(function(part) {
|
|
39183
39532
|
writer2.write(parseInt(part, 10), 8);
|
|
39184
39533
|
});
|
|
39185
39534
|
};
|
|
@@ -39196,10 +39545,10 @@ Packet$9.Resource.MX = function(exchange, priority) {
|
|
|
39196
39545
|
this.priority = priority;
|
|
39197
39546
|
return this;
|
|
39198
39547
|
};
|
|
39199
|
-
Packet$9.Resource.MX.encode = function(
|
|
39548
|
+
Packet$9.Resource.MX.encode = function(record2, writer2) {
|
|
39200
39549
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39201
|
-
writer2.write(
|
|
39202
|
-
Packet$9.Name.encode(
|
|
39550
|
+
writer2.write(record2.priority, 16);
|
|
39551
|
+
Packet$9.Name.encode(record2.exchange, writer2);
|
|
39203
39552
|
};
|
|
39204
39553
|
Packet$9.Resource.MX.decode = function(reader2, length2) {
|
|
39205
39554
|
this.priority = reader2.read(16);
|
|
@@ -39216,9 +39565,9 @@ Packet$9.Resource.AAAA = {
|
|
|
39216
39565
|
this.address = toIPv6(parts);
|
|
39217
39566
|
return this;
|
|
39218
39567
|
},
|
|
39219
|
-
encode: function(
|
|
39568
|
+
encode: function(record2, writer2) {
|
|
39220
39569
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39221
|
-
fromIPv6(
|
|
39570
|
+
fromIPv6(record2.address).forEach(function(part) {
|
|
39222
39571
|
writer2.write(parseInt(part, 16), 16);
|
|
39223
39572
|
});
|
|
39224
39573
|
}
|
|
@@ -39228,9 +39577,9 @@ Packet$9.Resource.NS = {
|
|
|
39228
39577
|
this.ns = Packet$9.Name.decode(reader2);
|
|
39229
39578
|
return this;
|
|
39230
39579
|
},
|
|
39231
|
-
encode: function(
|
|
39580
|
+
encode: function(record2, writer2) {
|
|
39232
39581
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39233
|
-
Packet$9.Name.encode(
|
|
39582
|
+
Packet$9.Name.encode(record2.ns, writer2);
|
|
39234
39583
|
}
|
|
39235
39584
|
};
|
|
39236
39585
|
Packet$9.Resource.PTR = Packet$9.Resource.CNAME = {
|
|
@@ -39238,9 +39587,9 @@ Packet$9.Resource.PTR = Packet$9.Resource.CNAME = {
|
|
|
39238
39587
|
this.domain = Packet$9.Name.decode(reader2);
|
|
39239
39588
|
return this;
|
|
39240
39589
|
},
|
|
39241
|
-
encode: function(
|
|
39590
|
+
encode: function(record2, writer2) {
|
|
39242
39591
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39243
|
-
Packet$9.Name.encode(
|
|
39592
|
+
Packet$9.Name.encode(record2.domain, writer2);
|
|
39244
39593
|
}
|
|
39245
39594
|
};
|
|
39246
39595
|
Packet$9.Resource.SPF = Packet$9.Resource.TXT = {
|
|
@@ -39259,9 +39608,9 @@ Packet$9.Resource.SPF = Packet$9.Resource.TXT = {
|
|
|
39259
39608
|
this.data = Buffer.from(parts).toString("utf8");
|
|
39260
39609
|
return this;
|
|
39261
39610
|
},
|
|
39262
|
-
encode: function(
|
|
39611
|
+
encode: function(record2, writer2) {
|
|
39263
39612
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39264
|
-
const characterStrings = Array.isArray(
|
|
39613
|
+
const characterStrings = Array.isArray(record2.data) ? record2.data : [record2.data];
|
|
39265
39614
|
const characterStringBuffers = characterStrings.map(function(characterString) {
|
|
39266
39615
|
if (Buffer.isBuffer(characterString)) {
|
|
39267
39616
|
return characterString;
|
|
@@ -39292,15 +39641,15 @@ Packet$9.Resource.SOA = {
|
|
|
39292
39641
|
this.minimum = reader2.read(32);
|
|
39293
39642
|
return this;
|
|
39294
39643
|
},
|
|
39295
|
-
encode: function(
|
|
39644
|
+
encode: function(record2, writer2) {
|
|
39296
39645
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39297
|
-
Packet$9.Name.encode(
|
|
39298
|
-
Packet$9.Name.encode(
|
|
39299
|
-
writer2.write(
|
|
39300
|
-
writer2.write(
|
|
39301
|
-
writer2.write(
|
|
39302
|
-
writer2.write(
|
|
39303
|
-
writer2.write(Math.min(
|
|
39646
|
+
Packet$9.Name.encode(record2.primary, writer2);
|
|
39647
|
+
Packet$9.Name.encode(record2.admin, writer2);
|
|
39648
|
+
writer2.write(record2.serial, 32);
|
|
39649
|
+
writer2.write(record2.refresh, 32);
|
|
39650
|
+
writer2.write(record2.retry, 32);
|
|
39651
|
+
writer2.write(record2.expiration, 32);
|
|
39652
|
+
writer2.write(Math.min(record2.minimum >>> 0, 2147483647), 32);
|
|
39304
39653
|
}
|
|
39305
39654
|
};
|
|
39306
39655
|
Packet$9.Resource.SRV = {
|
|
@@ -39311,12 +39660,12 @@ Packet$9.Resource.SRV = {
|
|
|
39311
39660
|
this.target = Packet$9.Name.decode(reader2);
|
|
39312
39661
|
return this;
|
|
39313
39662
|
},
|
|
39314
|
-
encode: function(
|
|
39663
|
+
encode: function(record2, writer2) {
|
|
39315
39664
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39316
|
-
writer2.write(
|
|
39317
|
-
writer2.write(
|
|
39318
|
-
writer2.write(
|
|
39319
|
-
Packet$9.Name.encode(
|
|
39665
|
+
writer2.write(record2.priority, 16);
|
|
39666
|
+
writer2.write(record2.weight, 16);
|
|
39667
|
+
writer2.write(record2.port, 16);
|
|
39668
|
+
Packet$9.Name.encode(record2.target, writer2);
|
|
39320
39669
|
}
|
|
39321
39670
|
};
|
|
39322
39671
|
const ednsTtl = (extendedRcode, version2, doFlag) => (extendedRcode & 255) << 24 >>> 0 | (version2 & 255) << 16 | (doFlag ? 32768 : 0);
|
|
@@ -39369,9 +39718,9 @@ Packet$9.Resource.EDNS.decode = function(reader2, length2) {
|
|
|
39369
39718
|
}
|
|
39370
39719
|
return this;
|
|
39371
39720
|
};
|
|
39372
|
-
Packet$9.Resource.EDNS.encode = function(
|
|
39721
|
+
Packet$9.Resource.EDNS.encode = function(record2, writer2) {
|
|
39373
39722
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39374
|
-
for (const rdata of
|
|
39723
|
+
for (const rdata of record2.rdata) {
|
|
39375
39724
|
const encoder = Object.keys(Packet$9.EDNS_OPTION_CODE).filter(
|
|
39376
39725
|
function(type2) {
|
|
39377
39726
|
return rdata.ednsCode === Packet$9.EDNS_OPTION_CODE[type2];
|
|
@@ -39434,18 +39783,18 @@ Packet$9.Resource.EDNS.ECS.decode = function(reader2, length2) {
|
|
|
39434
39783
|
}
|
|
39435
39784
|
return rdata;
|
|
39436
39785
|
};
|
|
39437
|
-
Packet$9.Resource.EDNS.ECS.encode = function(
|
|
39438
|
-
const octets = Math.ceil(
|
|
39439
|
-
writer2.write(
|
|
39440
|
-
writer2.write(
|
|
39441
|
-
writer2.write(
|
|
39786
|
+
Packet$9.Resource.EDNS.ECS.encode = function(record2, writer2) {
|
|
39787
|
+
const octets = Math.ceil(record2.sourcePrefixLength / 8);
|
|
39788
|
+
writer2.write(record2.family, 16);
|
|
39789
|
+
writer2.write(record2.sourcePrefixLength, 8);
|
|
39790
|
+
writer2.write(record2.scopePrefixLength, 8);
|
|
39442
39791
|
let bytes;
|
|
39443
|
-
if (
|
|
39444
|
-
bytes =
|
|
39445
|
-
} else if (
|
|
39446
|
-
bytes = expandIPv6ToBytes(
|
|
39792
|
+
if (record2.family === 1) {
|
|
39793
|
+
bytes = record2.ip.split(".").map((s) => parseInt(s, 10) || 0);
|
|
39794
|
+
} else if (record2.family === 2) {
|
|
39795
|
+
bytes = expandIPv6ToBytes(record2.ip);
|
|
39447
39796
|
} else {
|
|
39448
|
-
throw new Error(`EDNS.ECS encode: unsupported family ${
|
|
39797
|
+
throw new Error(`EDNS.ECS encode: unsupported family ${record2.family}`);
|
|
39449
39798
|
}
|
|
39450
39799
|
for (let i2 = 0; i2 < octets; i2++) {
|
|
39451
39800
|
writer2.write(bytes[i2] || 0, 8);
|
|
@@ -39472,11 +39821,11 @@ function expandIPv6ToBytes(address) {
|
|
|
39472
39821
|
return out;
|
|
39473
39822
|
}
|
|
39474
39823
|
Packet$9.Resource.CAA = {
|
|
39475
|
-
encode: function(
|
|
39824
|
+
encode: function(record2, writer2) {
|
|
39476
39825
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39477
|
-
const buffer = Buffer.from(
|
|
39478
|
-
writer2.write(
|
|
39479
|
-
writer2.write(
|
|
39826
|
+
const buffer = Buffer.from(record2.tag + record2.value, "utf8");
|
|
39827
|
+
writer2.write(record2.flags, 8);
|
|
39828
|
+
writer2.write(record2.tag.length, 8);
|
|
39480
39829
|
buffer.forEach(function(c) {
|
|
39481
39830
|
writer2.write(c, 8);
|
|
39482
39831
|
});
|
|
@@ -39517,12 +39866,12 @@ Packet$9.Resource.DNSKEY = {
|
|
|
39517
39866
|
this.key = Buffer.from(RData.slice(4)).toString("base64");
|
|
39518
39867
|
return this;
|
|
39519
39868
|
},
|
|
39520
|
-
encode: function(
|
|
39869
|
+
encode: function(record2, writer2) {
|
|
39521
39870
|
writer2 = writer2 || new Packet$9.Writer();
|
|
39522
|
-
const buffer = Buffer.from(
|
|
39523
|
-
writer2.write(
|
|
39524
|
-
writer2.write(
|
|
39525
|
-
writer2.write(
|
|
39871
|
+
const buffer = Buffer.from(record2.key, "base64");
|
|
39872
|
+
writer2.write(record2.flags, 16);
|
|
39873
|
+
writer2.write(record2.protocol, 8);
|
|
39874
|
+
writer2.write(record2.algorithm, 8);
|
|
39526
39875
|
buffer.forEach(function(c) {
|
|
39527
39876
|
writer2.write(c, 8);
|
|
39528
39877
|
});
|
|
@@ -39578,9 +39927,9 @@ Packet$9.createResponseFromRequest = function(request) {
|
|
|
39578
39927
|
response.questions = request.questions.slice();
|
|
39579
39928
|
return response;
|
|
39580
39929
|
};
|
|
39581
|
-
Packet$9.createResourceFromQuestion = function(base,
|
|
39930
|
+
Packet$9.createResourceFromQuestion = function(base, record2) {
|
|
39582
39931
|
const resource = new Packet$9.Resource(base);
|
|
39583
|
-
Object.assign(resource,
|
|
39932
|
+
Object.assign(resource, record2);
|
|
39584
39933
|
return resource;
|
|
39585
39934
|
};
|
|
39586
39935
|
Packet$9.readStream = (socket2) => {
|
|
@@ -41396,17 +41745,23 @@ function looksLikeBackup(basename) {
|
|
|
41396
41745
|
function looksNonLocal(basename) {
|
|
41397
41746
|
return NON_LOCAL_SUFFIX_PATTERNS.some((re) => re.test(basename));
|
|
41398
41747
|
}
|
|
41748
|
+
function isEnvTargetName(basename, appType) {
|
|
41749
|
+
if (basename === ".env.local" || basename.startsWith(".env.local.")) return true;
|
|
41750
|
+
return basename === envFileNameForType(appType);
|
|
41751
|
+
}
|
|
41399
41752
|
async function scanEnvTargets(projectId) {
|
|
41400
41753
|
const store2 = useStore.getState();
|
|
41401
41754
|
const project = store2.getProject(projectId);
|
|
41402
41755
|
if (!project?.path) return [];
|
|
41403
|
-
const roots = [
|
|
41756
|
+
const roots = [
|
|
41757
|
+
{ dir: project.path, appType: (project.apps ?? []).find((a) => a.path === project.path)?.type }
|
|
41758
|
+
];
|
|
41404
41759
|
for (const app of project.apps ?? []) {
|
|
41405
|
-
if (app.path && app.path !== project.path) roots.push(app.path);
|
|
41760
|
+
if (app.path && app.path !== project.path) roots.push({ dir: app.path, appType: app.type });
|
|
41406
41761
|
}
|
|
41407
41762
|
const targets = [];
|
|
41408
41763
|
const seen2 = /* @__PURE__ */ new Set();
|
|
41409
|
-
for (const root of roots) {
|
|
41764
|
+
for (const { dir: root, appType } of roots) {
|
|
41410
41765
|
let entries;
|
|
41411
41766
|
try {
|
|
41412
41767
|
entries = await fs.readdir(root);
|
|
@@ -41414,7 +41769,7 @@ async function scanEnvTargets(projectId) {
|
|
|
41414
41769
|
continue;
|
|
41415
41770
|
}
|
|
41416
41771
|
for (const name2 of entries) {
|
|
41417
|
-
if (name2
|
|
41772
|
+
if (!isEnvTargetName(name2, appType)) continue;
|
|
41418
41773
|
if (looksNonLocal(name2)) continue;
|
|
41419
41774
|
if (looksLikeBackup(name2)) continue;
|
|
41420
41775
|
const filePath = path.join(root, name2);
|
|
@@ -41462,7 +41817,7 @@ function mergeEnvContent(existing, newVars) {
|
|
|
41462
41817
|
}
|
|
41463
41818
|
function expandVarsForExistingFile(vars, existingContent) {
|
|
41464
41819
|
const MIRRORED_BASES = ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_STUDIO_URL"];
|
|
41465
|
-
const PREFIXES =
|
|
41820
|
+
const PREFIXES = ALL_ENV_PREFIXES;
|
|
41466
41821
|
const out = { ...vars };
|
|
41467
41822
|
const existingKeys = /* @__PURE__ */ new Set();
|
|
41468
41823
|
for (const line of existingContent.split("\n")) {
|
|
@@ -41620,6 +41975,7 @@ const connectionExporter = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.
|
|
|
41620
41975
|
ensureGitignoreEntry,
|
|
41621
41976
|
formatEnvBody,
|
|
41622
41977
|
isCloudSupabaseUrl,
|
|
41978
|
+
isEnvTargetName,
|
|
41623
41979
|
looksLikeBackup,
|
|
41624
41980
|
looksNonLocal,
|
|
41625
41981
|
match1,
|
|
@@ -43894,6 +44250,15 @@ const TOOL_SCOPE_MAP = {
|
|
|
43894
44250
|
get_port_forwarding_status: "read",
|
|
43895
44251
|
set_port_forwarding: "system",
|
|
43896
44252
|
reload_port_forwarding: "system",
|
|
44253
|
+
// project readiness — read-only; it spawns nothing and writes nothing.
|
|
44254
|
+
get_project_readiness: "read",
|
|
44255
|
+
// Installing writes into the project directory, so it takes the same scope as any
|
|
44256
|
+
// other per-project write; cancelling an install it started must not need a wider one.
|
|
44257
|
+
install_dependencies: "projects",
|
|
44258
|
+
cancel_install: "projects",
|
|
44259
|
+
// Reading an install's output changes nothing — and a client that can see a project
|
|
44260
|
+
// can see why its install failed.
|
|
44261
|
+
tail_install_log: "read",
|
|
43897
44262
|
// Group 4 gap tools: misc daemon-state
|
|
43898
44263
|
cleanup_isolation: "services",
|
|
43899
44264
|
// DESTRUCTIVE — plan-gated
|
|
@@ -43946,8 +44311,12 @@ class RateLimiter {
|
|
|
43946
44311
|
const TOOLS_WITH_SECRETS = /* @__PURE__ */ new Set([
|
|
43947
44312
|
"write_env_file",
|
|
43948
44313
|
"write_supabase_config",
|
|
43949
|
-
"cloud_sign_in"
|
|
44314
|
+
"cloud_sign_in",
|
|
43950
44315
|
// carries an account password — must never land in the audit log
|
|
44316
|
+
"preview_connection",
|
|
44317
|
+
"diff_env",
|
|
44318
|
+
"apply_env",
|
|
44319
|
+
"write_connection"
|
|
43951
44320
|
]);
|
|
43952
44321
|
function redactScalar(v) {
|
|
43953
44322
|
if (v == null) return "<redacted:null>";
|
|
@@ -44477,6 +44846,891 @@ const logTailTools = {
|
|
|
44477
44846
|
return useStore.getState().requestLogs.find((l) => l.id === a.id) ?? null;
|
|
44478
44847
|
}
|
|
44479
44848
|
};
|
|
44849
|
+
function normalizeRelative(input) {
|
|
44850
|
+
const trimmed = normalizeDirPath(input);
|
|
44851
|
+
return trimmed === "." ? "" : trimmed;
|
|
44852
|
+
}
|
|
44853
|
+
function declaresWorkspaces(raw) {
|
|
44854
|
+
if (raw === null) return false;
|
|
44855
|
+
try {
|
|
44856
|
+
const parsed = JSON.parse(raw);
|
|
44857
|
+
return typeof parsed === "object" && parsed !== null && "workspaces" in parsed;
|
|
44858
|
+
} catch {
|
|
44859
|
+
return false;
|
|
44860
|
+
}
|
|
44861
|
+
}
|
|
44862
|
+
async function describeRoot(projectPath, dir, entries, io2) {
|
|
44863
|
+
const detected = await detectPackageManagerAt(dir, io2);
|
|
44864
|
+
const manager = detected?.manager ?? "npm";
|
|
44865
|
+
const managerSource = detected?.managerSource ?? "default";
|
|
44866
|
+
const nodeLinker = await detectNodeLinkerAt(dir, manager, io2);
|
|
44867
|
+
const relative = normalizeRelative(path.posix.relative(projectPath, dir));
|
|
44868
|
+
return {
|
|
44869
|
+
path: relative,
|
|
44870
|
+
manager,
|
|
44871
|
+
managerSource,
|
|
44872
|
+
...detected?.managerVersion ? { managerVersion: detected.managerVersion } : {},
|
|
44873
|
+
nodeLinker,
|
|
44874
|
+
// EMPTY WHEN NOTHING IDENTIFIED THE MANAGER — a guess must not be spawnable.
|
|
44875
|
+
// `manager` above carries 'npm' only because the field is not nullable, so a populated
|
|
44876
|
+
// `['npm', 'install']` on a `'default'` root would be an ASSUMPTION wearing the exact
|
|
44877
|
+
// shape of an observation, and `installArgv` ships on the wire. A later phase's install
|
|
44878
|
+
// button or `--fix` line that spawns it directly — rather than reading `fix.command`,
|
|
44879
|
+
// which `deps.ts` deliberately withholds for this case — would then run npm inside what
|
|
44880
|
+
// may well be a pnpm repo and write a stray `package-lock.json` into the user's working
|
|
44881
|
+
// tree, the precise outcome the spec's strictly-ordered precedence table exists to
|
|
44882
|
+
// prevent. `deps.ts` offers no fix for such a root today, but "no caller does the wrong
|
|
44883
|
+
// thing" is a property of today's callers, not of the data. An empty argv spawns
|
|
44884
|
+
// nothing and spreads harmlessly, and the type stays `string[]` so no consumer has to
|
|
44885
|
+
// grow a null check to stay safe.
|
|
44886
|
+
installArgv: managerSource === "default" ? [] : installArgvFor(manager),
|
|
44887
|
+
managerAvailable: await io2.hasBinary(manager),
|
|
44888
|
+
isWorkspaceRoot: entries.has("pnpm-workspace.yaml") || declaresWorkspaces(await io2.readFile(path.posix.join(dir, "package.json")))
|
|
44889
|
+
};
|
|
44890
|
+
}
|
|
44891
|
+
async function enumerateInstallRoots(projectPath, apps, io2) {
|
|
44892
|
+
const root = normalizeDirPath(projectPath);
|
|
44893
|
+
const roots = [];
|
|
44894
|
+
const rootEntries = new Set(await io2.readDir(root));
|
|
44895
|
+
if (rootEntries.has("package.json")) {
|
|
44896
|
+
roots.push(await describeRoot(root, root, rootEntries, io2));
|
|
44897
|
+
}
|
|
44898
|
+
const seen2 = new Set(roots.map((r) => r.path));
|
|
44899
|
+
for (const app of apps) {
|
|
44900
|
+
const relative = normalizeRelative(app);
|
|
44901
|
+
if (relative === "" || relative.startsWith("..") || seen2.has(relative)) continue;
|
|
44902
|
+
const dir = path.posix.join(root, relative);
|
|
44903
|
+
const entries = new Set(await io2.readDir(dir));
|
|
44904
|
+
if (!LOCKFILE_NAMES.some((name2) => entries.has(name2))) continue;
|
|
44905
|
+
seen2.add(relative);
|
|
44906
|
+
roots.push(await describeRoot(root, dir, entries, io2));
|
|
44907
|
+
}
|
|
44908
|
+
return roots.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
44909
|
+
}
|
|
44910
|
+
const RECEIPTS = {
|
|
44911
|
+
pnpm: {
|
|
44912
|
+
"node-modules": "node_modules/.modules.yaml",
|
|
44913
|
+
// pnpm has no PnP mode; the linker is fixed at `node-modules` by
|
|
44914
|
+
// `detectNodeLinkerAt`, and this entry exists only to keep the table total.
|
|
44915
|
+
pnp: "node_modules/.modules.yaml"
|
|
44916
|
+
},
|
|
44917
|
+
npm: {
|
|
44918
|
+
"node-modules": "node_modules/.package-lock.json",
|
|
44919
|
+
pnp: "node_modules/.package-lock.json"
|
|
44920
|
+
},
|
|
44921
|
+
yarn: {
|
|
44922
|
+
"node-modules": "node_modules/.yarn-state.yml",
|
|
44923
|
+
pnp: ".yarn/install-state.gz"
|
|
44924
|
+
},
|
|
44925
|
+
bun: {
|
|
44926
|
+
"node-modules": "node_modules/.bun-tag",
|
|
44927
|
+
pnp: "node_modules/.bun-tag"
|
|
44928
|
+
}
|
|
44929
|
+
};
|
|
44930
|
+
const FALLBACK_RECEIPTS = {
|
|
44931
|
+
pnpm: {},
|
|
44932
|
+
npm: {},
|
|
44933
|
+
yarn: { "node-modules": ["node_modules/.yarn-integrity"] },
|
|
44934
|
+
bun: { "node-modules": ["node_modules"], pnp: ["node_modules"] }
|
|
44935
|
+
};
|
|
44936
|
+
function receiptPathFor(dir, manager, linker) {
|
|
44937
|
+
return path.posix.join(dir, RECEIPTS[manager][linker]);
|
|
44938
|
+
}
|
|
44939
|
+
function lockfilePathsFor(dir, manager) {
|
|
44940
|
+
return LOCKFILES_BY_MANAGER[manager].map((name2) => path.posix.join(dir, name2));
|
|
44941
|
+
}
|
|
44942
|
+
async function newestMtime(paths, io2) {
|
|
44943
|
+
let newest = null;
|
|
44944
|
+
for (const candidate of paths) {
|
|
44945
|
+
const mtime = await io2.statMtimeMs(candidate);
|
|
44946
|
+
if (mtime === null) continue;
|
|
44947
|
+
if (newest === null || mtime > newest) newest = mtime;
|
|
44948
|
+
}
|
|
44949
|
+
return newest;
|
|
44950
|
+
}
|
|
44951
|
+
async function detectInstallState(dir, manager, linker, io2) {
|
|
44952
|
+
try {
|
|
44953
|
+
let receiptMtime = await io2.statMtimeMs(receiptPathFor(dir, manager, linker));
|
|
44954
|
+
if (receiptMtime === null) {
|
|
44955
|
+
for (const fallback of FALLBACK_RECEIPTS[manager][linker] ?? []) {
|
|
44956
|
+
receiptMtime = await io2.statMtimeMs(path.posix.join(dir, fallback));
|
|
44957
|
+
if (receiptMtime !== null) break;
|
|
44958
|
+
}
|
|
44959
|
+
}
|
|
44960
|
+
if (receiptMtime === null) return "no";
|
|
44961
|
+
const lockfileMtime = await newestMtime(lockfilePathsFor(dir, manager), io2);
|
|
44962
|
+
if (lockfileMtime === null) return "yes";
|
|
44963
|
+
return lockfileMtime > receiptMtime ? "stale" : "yes";
|
|
44964
|
+
} catch {
|
|
44965
|
+
return "unknown";
|
|
44966
|
+
}
|
|
44967
|
+
}
|
|
44968
|
+
async function existingLockfiles(root, absDir, io2) {
|
|
44969
|
+
const found = [];
|
|
44970
|
+
for (const candidate of lockfilePathsFor(absDir, root.manager)) {
|
|
44971
|
+
if (await io2.statMtimeMs(candidate) === null) continue;
|
|
44972
|
+
found.push(path.posix.join(root.path, path.posix.basename(candidate)));
|
|
44973
|
+
}
|
|
44974
|
+
return found;
|
|
44975
|
+
}
|
|
44976
|
+
function installFixFor(root, projectId) {
|
|
44977
|
+
return {
|
|
44978
|
+
label: `Run ${root.manager} install`,
|
|
44979
|
+
tool: "install_dependencies",
|
|
44980
|
+
args: { project: projectId, root: root.path },
|
|
44981
|
+
destructive: true,
|
|
44982
|
+
command: root.installArgv.join(" ")
|
|
44983
|
+
};
|
|
44984
|
+
}
|
|
44985
|
+
async function findingsForRoot(root, absDir, projectId, io2) {
|
|
44986
|
+
const label = rootLabel(root.path);
|
|
44987
|
+
const where = rootPhrase(root.path);
|
|
44988
|
+
const identified = root.managerSource !== "default";
|
|
44989
|
+
if (!root.managerAvailable && identified) {
|
|
44990
|
+
return [
|
|
44991
|
+
{
|
|
44992
|
+
id: `toolchain.manager-unavailable:${label}`,
|
|
44993
|
+
severity: "error",
|
|
44994
|
+
category: "toolchain",
|
|
44995
|
+
title: `${root.manager} is not available`,
|
|
44996
|
+
detail: `${where} needs ${root.manager}, but no ${root.manager} binary is resolvable on Supbuddy's augmented PATH. Install it (or make it visible to the app) — Supbuddy does not install package managers.`,
|
|
44997
|
+
evidence: [path.posix.join(root.path, "package.json")],
|
|
44998
|
+
goToTab: "scripts"
|
|
44999
|
+
}
|
|
45000
|
+
];
|
|
45001
|
+
}
|
|
45002
|
+
if (root.installed === "yes" || root.installed === "unknown") return [];
|
|
45003
|
+
const evidence = [
|
|
45004
|
+
...await existingLockfiles(root, absDir, io2),
|
|
45005
|
+
receiptPathFor(root.path, root.manager, root.nodeLinker)
|
|
45006
|
+
];
|
|
45007
|
+
const fix = identified ? installFixFor(root, projectId) : void 0;
|
|
45008
|
+
if (root.installed === "no") {
|
|
45009
|
+
const severity = identified ? "error" : "warning";
|
|
45010
|
+
return [
|
|
45011
|
+
{
|
|
45012
|
+
id: `deps.missing:${label}`,
|
|
45013
|
+
severity,
|
|
45014
|
+
category: "deps",
|
|
45015
|
+
title: root.path === "" ? "Dependencies are not installed" : `Dependencies are not installed in ${root.path}`,
|
|
45016
|
+
detail: identified ? `No completed ${root.manager} install was found for ${where}. Scripts in this root will fail until dependencies are installed.` : `Nothing has been installed in ${where}, and no packageManager field or lockfile identifies which package manager this project uses — so Supbuddy will not guess an install command for it.`,
|
|
45017
|
+
evidence,
|
|
45018
|
+
goToTab: "scripts",
|
|
45019
|
+
...fix ? { fix } : {}
|
|
45020
|
+
}
|
|
45021
|
+
];
|
|
45022
|
+
}
|
|
45023
|
+
return [
|
|
45024
|
+
{
|
|
45025
|
+
id: `deps.stale:${label}`,
|
|
45026
|
+
severity: "warning",
|
|
45027
|
+
category: "deps",
|
|
45028
|
+
title: root.path === "" ? "Dependencies are out of date" : `Dependencies are out of date in ${root.path}`,
|
|
45029
|
+
detail: `The lockfile in ${where} has changed since the last completed ${root.manager} install, so the installed tree no longer matches what the repo asks for.`,
|
|
45030
|
+
evidence,
|
|
45031
|
+
goToTab: "scripts",
|
|
45032
|
+
...fix ? { fix } : {}
|
|
45033
|
+
}
|
|
45034
|
+
];
|
|
45035
|
+
}
|
|
45036
|
+
async function detectDependencyReadiness(input, io2) {
|
|
45037
|
+
const projectRoot = normalizeDirPath(input.projectPath);
|
|
45038
|
+
const enumerated = await enumerateInstallRoots(input.projectPath, input.apps, io2);
|
|
45039
|
+
const installRoots = [];
|
|
45040
|
+
const findings = [];
|
|
45041
|
+
for (const partial of enumerated) {
|
|
45042
|
+
const absDir = path.posix.join(projectRoot, partial.path);
|
|
45043
|
+
const root = {
|
|
45044
|
+
...partial,
|
|
45045
|
+
installed: await detectInstallState(absDir, partial.manager, partial.nodeLinker, io2)
|
|
45046
|
+
};
|
|
45047
|
+
installRoots.push(root);
|
|
45048
|
+
findings.push(...await findingsForRoot(root, absDir, input.projectId, io2));
|
|
45049
|
+
}
|
|
45050
|
+
return { installRoots, findings };
|
|
45051
|
+
}
|
|
45052
|
+
const EXAMPLE_FILE_NAMES = [".env.example", ".env.sample", ".env.template"];
|
|
45053
|
+
const ENV_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
45054
|
+
function absoluteDir(projectPath, appPath) {
|
|
45055
|
+
if (!appPath) return projectPath;
|
|
45056
|
+
if (path.isAbsolute(appPath)) return appPath;
|
|
45057
|
+
return path.join(projectPath, appPath);
|
|
45058
|
+
}
|
|
45059
|
+
function envFilesReadBy(type2) {
|
|
45060
|
+
const preferred = envFileNameForType(type2);
|
|
45061
|
+
return preferred === ".env" ? [preferred] : [preferred, ".env"];
|
|
45062
|
+
}
|
|
45063
|
+
function declaredKeys(content) {
|
|
45064
|
+
const out = [];
|
|
45065
|
+
for (const raw of Object.keys(parseDotenv(content))) {
|
|
45066
|
+
const name2 = raw.replace(/^export\s+/, "").trim();
|
|
45067
|
+
if (!ENV_IDENTIFIER.test(name2) || out.includes(name2)) continue;
|
|
45068
|
+
out.push(name2);
|
|
45069
|
+
}
|
|
45070
|
+
return out;
|
|
45071
|
+
}
|
|
45072
|
+
async function readExample(absDir, io2) {
|
|
45073
|
+
for (const name2 of EXAMPLE_FILE_NAMES) {
|
|
45074
|
+
const content = await io2.readFile(path.join(absDir, name2));
|
|
45075
|
+
if (content === null) continue;
|
|
45076
|
+
return { name: name2, keys: declaredKeys(content) };
|
|
45077
|
+
}
|
|
45078
|
+
return null;
|
|
45079
|
+
}
|
|
45080
|
+
async function readTargets(absDir, type2, io2) {
|
|
45081
|
+
const candidates = envFilesReadBy(type2);
|
|
45082
|
+
const setKeys = /* @__PURE__ */ new Set();
|
|
45083
|
+
let firstExisting2 = null;
|
|
45084
|
+
for (const name2 of candidates) {
|
|
45085
|
+
const content = await io2.readFile(path.join(absDir, name2));
|
|
45086
|
+
if (content === null) continue;
|
|
45087
|
+
if (firstExisting2 === null) firstExisting2 = name2;
|
|
45088
|
+
for (const key of declaredKeys(content)) setKeys.add(key);
|
|
45089
|
+
}
|
|
45090
|
+
return { name: firstExisting2 ?? candidates[0], setKeys };
|
|
45091
|
+
}
|
|
45092
|
+
async function accountedEnvKeys(absDir, type2, io2) {
|
|
45093
|
+
const target = await readTargets(absDir, type2, io2);
|
|
45094
|
+
const keys = new Set(target.setKeys);
|
|
45095
|
+
const example = await readExample(absDir, io2);
|
|
45096
|
+
for (const key of example?.keys ?? []) keys.add(key);
|
|
45097
|
+
return keys;
|
|
45098
|
+
}
|
|
45099
|
+
async function declaredEnvFindings(input, io2) {
|
|
45100
|
+
const findings = [];
|
|
45101
|
+
const seen2 = /* @__PURE__ */ new Set();
|
|
45102
|
+
for (const app of input.apps ?? []) {
|
|
45103
|
+
const absDir = absoluteDir(input.projectPath, app.path);
|
|
45104
|
+
if (seen2.has(absDir)) continue;
|
|
45105
|
+
seen2.add(absDir);
|
|
45106
|
+
const example = await readExample(absDir, io2);
|
|
45107
|
+
if (!example || example.keys.length === 0) continue;
|
|
45108
|
+
const target = await readTargets(absDir, app.type, io2);
|
|
45109
|
+
const missing = example.keys.filter((key) => !target.setKeys.has(key));
|
|
45110
|
+
if (missing.length === 0) continue;
|
|
45111
|
+
const appRel = projectRelative(input.projectPath, app.path);
|
|
45112
|
+
const exampleRel = projectRelative(input.projectPath, path.posix.join(appRel, example.name));
|
|
45113
|
+
const targetRel = projectRelative(input.projectPath, path.posix.join(appRel, target.name));
|
|
45114
|
+
const count = missing.length;
|
|
45115
|
+
const plural2 = count === 1 ? "" : "s";
|
|
45116
|
+
findings.push({
|
|
45117
|
+
id: `env.declared:${rootLabel(appRel)}`,
|
|
45118
|
+
severity: "warning",
|
|
45119
|
+
category: "env",
|
|
45120
|
+
title: `${count} env var${plural2} declared in ${exampleRel} ${count === 1 ? "is" : "are"} not set`,
|
|
45121
|
+
detail: `${exampleRel} lists ${count} key${plural2} that ${targetRel} does not set, so ${rootPhrase(appRel)} will read ${count === 1 ? "it" : "them"} as undefined at runtime. Supbuddy has no value to supply for ${count === 1 ? "it" : "them"} — these are the project's own credentials and settings — so it names ${count === 1 ? "it" : "them"} rather than inventing one.`,
|
|
45122
|
+
evidence: [exampleRel, targetRel, ...missing.map((key) => `${key} (unset)`)],
|
|
45123
|
+
goToTab: "apps"
|
|
45124
|
+
});
|
|
45125
|
+
}
|
|
45126
|
+
return findings;
|
|
45127
|
+
}
|
|
45128
|
+
function absoluteTargetFile(projectPath, targetPath) {
|
|
45129
|
+
if (!targetPath) return null;
|
|
45130
|
+
if (path.isAbsolute(targetPath)) return targetPath;
|
|
45131
|
+
if (!path.isAbsolute(projectPath)) return null;
|
|
45132
|
+
return path.join(projectPath, targetPath);
|
|
45133
|
+
}
|
|
45134
|
+
function keyNames(keys) {
|
|
45135
|
+
const out = [];
|
|
45136
|
+
for (const raw of keys ?? []) {
|
|
45137
|
+
const name2 = String(raw).split("=")[0].trim();
|
|
45138
|
+
if (!name2 || out.includes(name2)) continue;
|
|
45139
|
+
out.push(name2);
|
|
45140
|
+
}
|
|
45141
|
+
return out;
|
|
45142
|
+
}
|
|
45143
|
+
function resolveApp(input, targetDir) {
|
|
45144
|
+
const apps = input.apps ?? [];
|
|
45145
|
+
const byPath = apps.find((a) => projectRelative(input.projectPath, a.path) === targetDir);
|
|
45146
|
+
if (byPath) return byPath;
|
|
45147
|
+
const appName = input.drift?.targetAppName;
|
|
45148
|
+
const byName = appName ? apps.find((a) => a.name === appName) : void 0;
|
|
45149
|
+
if (byName) return byName;
|
|
45150
|
+
return apps[0];
|
|
45151
|
+
}
|
|
45152
|
+
function applyEnvFix(projectId, filePath, prefix) {
|
|
45153
|
+
return {
|
|
45154
|
+
label: "Set env vars",
|
|
45155
|
+
tool: "apply_env",
|
|
45156
|
+
args: {
|
|
45157
|
+
project_id: projectId,
|
|
45158
|
+
file_path: filePath,
|
|
45159
|
+
prefix,
|
|
45160
|
+
include_service_role_key: false
|
|
45161
|
+
},
|
|
45162
|
+
destructive: true
|
|
45163
|
+
};
|
|
45164
|
+
}
|
|
45165
|
+
function ownedEnvFindings(input) {
|
|
45166
|
+
const drift = input.drift;
|
|
45167
|
+
if (!drift) return [];
|
|
45168
|
+
if (drift.state !== "missing" && drift.state !== "stale") return [];
|
|
45169
|
+
const relTarget = projectRelative(input.projectPath, drift.targetPath);
|
|
45170
|
+
const targetDir = relTarget ? projectRelative(input.projectPath, path.posix.dirname(relTarget)) : "";
|
|
45171
|
+
const app = resolveApp(input, targetDir);
|
|
45172
|
+
const appPath = app ? projectRelative(input.projectPath, app.path) : targetDir;
|
|
45173
|
+
const label = rootLabel(appPath);
|
|
45174
|
+
const where = rootPhrase(appPath);
|
|
45175
|
+
const absTarget = absoluteTargetFile(input.projectPath, drift.targetPath);
|
|
45176
|
+
const fix = absTarget ? applyEnvFix(input.projectId, absTarget, envPrefixForType(app?.type)) : void 0;
|
|
45177
|
+
const stale = drift.state === "stale";
|
|
45178
|
+
const staleKeys = stale ? keyNames(drift.staleKeys) : [];
|
|
45179
|
+
const evidence = [
|
|
45180
|
+
...relTarget ? [relTarget] : [],
|
|
45181
|
+
...staleKeys.map((key) => `${key} (stale)`)
|
|
45182
|
+
];
|
|
45183
|
+
const filePhrase = relTarget || "the app env file";
|
|
45184
|
+
if (!stale) {
|
|
45185
|
+
return [
|
|
45186
|
+
{
|
|
45187
|
+
id: `env.missing:${label}`,
|
|
45188
|
+
severity: "error",
|
|
45189
|
+
category: "env",
|
|
45190
|
+
title: appPath === "" ? "Supabase connection vars are not set" : `Supabase connection vars are not set in ${appPath}`,
|
|
45191
|
+
detail: `None of Supbuddy's connection vars are present in ${filePhrase}, the env file ${where} loads. Until they are written the app has no Supabase URL or keys to start from.`,
|
|
45192
|
+
evidence,
|
|
45193
|
+
goToTab: "supabase",
|
|
45194
|
+
...fix ? { fix } : {}
|
|
45195
|
+
}
|
|
45196
|
+
];
|
|
45197
|
+
}
|
|
45198
|
+
const count = staleKeys.length;
|
|
45199
|
+
return [
|
|
45200
|
+
{
|
|
45201
|
+
id: `env.stale:${label}`,
|
|
45202
|
+
severity: "warning",
|
|
45203
|
+
category: "env",
|
|
45204
|
+
title: appPath === "" ? "Supabase connection vars are out of date" : `Supabase connection vars are out of date in ${appPath}`,
|
|
45205
|
+
detail: (count > 0 ? `${count} connection var${count === 1 ? "" : "s"} in ${filePhrase} no longer match` : `Connection vars in ${filePhrase} no longer match`) + ` this project's live URLs, ports or keys.`,
|
|
45206
|
+
evidence,
|
|
45207
|
+
goToTab: "supabase",
|
|
45208
|
+
...fix ? { fix } : {}
|
|
45209
|
+
}
|
|
45210
|
+
];
|
|
45211
|
+
}
|
|
45212
|
+
const MAX_FILES = 2e3;
|
|
45213
|
+
const MAX_DEPTH = 64;
|
|
45214
|
+
const MAX_FILE_CHARS = 1024 * 1024;
|
|
45215
|
+
const HARD_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
45216
|
+
"node_modules",
|
|
45217
|
+
"dist",
|
|
45218
|
+
"build",
|
|
45219
|
+
".next",
|
|
45220
|
+
".nuxt",
|
|
45221
|
+
".svelte-kit",
|
|
45222
|
+
"coverage",
|
|
45223
|
+
".git"
|
|
45224
|
+
]);
|
|
45225
|
+
const NON_APP_DIRS = /* @__PURE__ */ new Set(["__tests__", "test", "tests"]);
|
|
45226
|
+
const NON_APP_FILE = /\.(?:test|spec|config)\./i;
|
|
45227
|
+
const SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
45228
|
+
".ts",
|
|
45229
|
+
".tsx",
|
|
45230
|
+
".mts",
|
|
45231
|
+
".cts",
|
|
45232
|
+
".js",
|
|
45233
|
+
".jsx",
|
|
45234
|
+
".mjs",
|
|
45235
|
+
".cjs",
|
|
45236
|
+
".svelte",
|
|
45237
|
+
".vue",
|
|
45238
|
+
".astro"
|
|
45239
|
+
]);
|
|
45240
|
+
const DENY_EXACT = /* @__PURE__ */ new Set([
|
|
45241
|
+
"NODE_ENV",
|
|
45242
|
+
"PORT",
|
|
45243
|
+
"CI",
|
|
45244
|
+
"HOME",
|
|
45245
|
+
"PATH",
|
|
45246
|
+
"TZ",
|
|
45247
|
+
"NEXT_RUNTIME",
|
|
45248
|
+
"MODE",
|
|
45249
|
+
"DEV",
|
|
45250
|
+
"PROD",
|
|
45251
|
+
"SSR",
|
|
45252
|
+
"BASE_URL",
|
|
45253
|
+
// A third block, from running this against a real repo: names the OPERATING SYSTEM or
|
|
45254
|
+
// the terminal supplies. `APPDATA`/`LOCALAPPDATA` are Windows' own, the rest come from
|
|
45255
|
+
// the shell that launched the process. No `.env.local` has ever set one, and a CLI that
|
|
45256
|
+
// reads `TERM` is not misconfigured. `GH_TOKEN` is deliberately NOT here: a project
|
|
45257
|
+
// that reads it is reading something the user really may have to provide.
|
|
45258
|
+
"APPDATA",
|
|
45259
|
+
"LOCALAPPDATA",
|
|
45260
|
+
"FORCE_COLOR",
|
|
45261
|
+
"NO_COLOR",
|
|
45262
|
+
"COLORTERM",
|
|
45263
|
+
"TERM",
|
|
45264
|
+
"SHELL",
|
|
45265
|
+
"EDITOR",
|
|
45266
|
+
"SUDO_ASKPASS"
|
|
45267
|
+
]);
|
|
45268
|
+
const DENY_PREFIXES = ["npm_", "VERCEL_", "XDG_"];
|
|
45269
|
+
const ENV_MEMBER = /(?:process\s*\.\s*env|import\s*\.\s*meta\s*\.\s*env)\s*(?:\.\s*([A-Za-z_][A-Za-z0-9_]*)|\[\s*(['"`])([A-Za-z_][A-Za-z0-9_]*)\2\s*\])/g;
|
|
45270
|
+
const DENO_ENV_GET = /Deno\s*\.\s*env\s*\.\s*get\s*\(\s*(['"`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\)/g;
|
|
45271
|
+
function isDenied(key) {
|
|
45272
|
+
if (DENY_EXACT.has(key)) return true;
|
|
45273
|
+
return DENY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
45274
|
+
}
|
|
45275
|
+
function hasSourceExtension(name2) {
|
|
45276
|
+
const dot = name2.lastIndexOf(".");
|
|
45277
|
+
return dot > 0 && SOURCE_EXTENSIONS.has(name2.slice(dot).toLowerCase());
|
|
45278
|
+
}
|
|
45279
|
+
function globToRegExp(pattern, anchored) {
|
|
45280
|
+
let body = "";
|
|
45281
|
+
for (let i2 = 0; i2 < pattern.length; i2++) {
|
|
45282
|
+
const ch = pattern[i2];
|
|
45283
|
+
if (ch === "*") {
|
|
45284
|
+
if (pattern[i2 + 1] === "*") {
|
|
45285
|
+
i2++;
|
|
45286
|
+
if (pattern[i2 + 1] === "/") {
|
|
45287
|
+
i2++;
|
|
45288
|
+
body += "(?:.*/)?";
|
|
45289
|
+
} else {
|
|
45290
|
+
body += ".*";
|
|
45291
|
+
}
|
|
45292
|
+
} else {
|
|
45293
|
+
body += "[^/]*";
|
|
45294
|
+
}
|
|
45295
|
+
} else if (ch === "?") {
|
|
45296
|
+
body += "[^/]";
|
|
45297
|
+
} else {
|
|
45298
|
+
body += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45299
|
+
}
|
|
45300
|
+
}
|
|
45301
|
+
return new RegExp(anchored ? `^${body}$` : `^(?:.*/)?${body}$`);
|
|
45302
|
+
}
|
|
45303
|
+
function parseGitignore(content) {
|
|
45304
|
+
const rules = [];
|
|
45305
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
45306
|
+
let line = rawLine.trim();
|
|
45307
|
+
if (!line || line.startsWith("#")) continue;
|
|
45308
|
+
if (line.startsWith("!")) continue;
|
|
45309
|
+
let dirOnly = false;
|
|
45310
|
+
if (line.endsWith("/")) {
|
|
45311
|
+
dirOnly = true;
|
|
45312
|
+
line = line.slice(0, -1);
|
|
45313
|
+
}
|
|
45314
|
+
if (!line) continue;
|
|
45315
|
+
const anchored = line.includes("/");
|
|
45316
|
+
if (line.startsWith("/")) line = line.slice(1);
|
|
45317
|
+
if (!line) continue;
|
|
45318
|
+
rules.push({ re: globToRegExp(line, anchored), dirOnly });
|
|
45319
|
+
}
|
|
45320
|
+
return rules;
|
|
45321
|
+
}
|
|
45322
|
+
function isIgnored(abs, isDir2, scopes) {
|
|
45323
|
+
for (const scope of scopes) {
|
|
45324
|
+
const rel = projectRelative(scope.dirAbs, abs);
|
|
45325
|
+
if (!rel || rel.startsWith("../")) continue;
|
|
45326
|
+
for (const rule of scope.rules) {
|
|
45327
|
+
if (rule.dirOnly && !isDir2) continue;
|
|
45328
|
+
if (rule.re.test(rel)) return true;
|
|
45329
|
+
}
|
|
45330
|
+
}
|
|
45331
|
+
return false;
|
|
45332
|
+
}
|
|
45333
|
+
async function withGitignoreAt(absDir, scopes, io2) {
|
|
45334
|
+
if (scopes.some((scope) => scope.dirAbs === absDir)) return [...scopes];
|
|
45335
|
+
const content = await io2.readFile(path.join(absDir, ".gitignore"));
|
|
45336
|
+
if (content === null) return [...scopes];
|
|
45337
|
+
const rules = parseGitignore(content);
|
|
45338
|
+
if (rules.length === 0) return [...scopes];
|
|
45339
|
+
return [...scopes, { dirAbs: absDir, rules }];
|
|
45340
|
+
}
|
|
45341
|
+
async function readSourceFile(abs, io2) {
|
|
45342
|
+
try {
|
|
45343
|
+
return await io2.readFile(abs);
|
|
45344
|
+
} catch (err) {
|
|
45345
|
+
if (err?.code === "EISDIR") return null;
|
|
45346
|
+
throw err;
|
|
45347
|
+
}
|
|
45348
|
+
}
|
|
45349
|
+
function record$1(key, where, found) {
|
|
45350
|
+
if (!key || isDenied(key) || found.has(key)) return;
|
|
45351
|
+
found.set(key, where);
|
|
45352
|
+
}
|
|
45353
|
+
function stripComments(source) {
|
|
45354
|
+
let out = "";
|
|
45355
|
+
let state = "code";
|
|
45356
|
+
let i2 = 0;
|
|
45357
|
+
while (i2 < source.length) {
|
|
45358
|
+
const ch = source[i2];
|
|
45359
|
+
const next = source[i2 + 1];
|
|
45360
|
+
if (state === "code") {
|
|
45361
|
+
if (ch === "/" && next === "/") {
|
|
45362
|
+
state = "line";
|
|
45363
|
+
out += " ";
|
|
45364
|
+
i2 += 2;
|
|
45365
|
+
continue;
|
|
45366
|
+
}
|
|
45367
|
+
if (ch === "/" && next === "*") {
|
|
45368
|
+
state = "block";
|
|
45369
|
+
out += " ";
|
|
45370
|
+
i2 += 2;
|
|
45371
|
+
continue;
|
|
45372
|
+
}
|
|
45373
|
+
if (ch === "'" || ch === '"' || ch === "`") state = ch;
|
|
45374
|
+
out += ch;
|
|
45375
|
+
i2 += 1;
|
|
45376
|
+
continue;
|
|
45377
|
+
}
|
|
45378
|
+
if (state === "line") {
|
|
45379
|
+
if (ch === "\n") state = "code";
|
|
45380
|
+
out += ch === "\n" ? ch : " ";
|
|
45381
|
+
i2 += 1;
|
|
45382
|
+
continue;
|
|
45383
|
+
}
|
|
45384
|
+
if (state === "block") {
|
|
45385
|
+
if (ch === "*" && next === "/") {
|
|
45386
|
+
state = "code";
|
|
45387
|
+
out += " ";
|
|
45388
|
+
i2 += 2;
|
|
45389
|
+
continue;
|
|
45390
|
+
}
|
|
45391
|
+
out += ch === "\n" ? ch : " ";
|
|
45392
|
+
i2 += 1;
|
|
45393
|
+
continue;
|
|
45394
|
+
}
|
|
45395
|
+
if (ch === "\\") {
|
|
45396
|
+
out += ch + (next ?? "");
|
|
45397
|
+
i2 += next === void 0 ? 1 : 2;
|
|
45398
|
+
continue;
|
|
45399
|
+
}
|
|
45400
|
+
if (ch === state) state = "code";
|
|
45401
|
+
else if (ch === "\n" && state !== "`") state = "code";
|
|
45402
|
+
out += ch;
|
|
45403
|
+
i2 += 1;
|
|
45404
|
+
}
|
|
45405
|
+
return out;
|
|
45406
|
+
}
|
|
45407
|
+
function scanSource(content, relFile, found) {
|
|
45408
|
+
const lines = stripComments(content).split("\n");
|
|
45409
|
+
for (let i2 = 0; i2 < lines.length; i2++) {
|
|
45410
|
+
const line = lines[i2];
|
|
45411
|
+
if (!line.includes("env")) continue;
|
|
45412
|
+
const where = `${relFile}:${i2 + 1}`;
|
|
45413
|
+
for (const match of line.matchAll(ENV_MEMBER)) record$1(match[1] ?? match[3], where, found);
|
|
45414
|
+
for (const match of line.matchAll(DENO_ENV_GET)) record$1(match[2], where, found);
|
|
45415
|
+
}
|
|
45416
|
+
}
|
|
45417
|
+
async function enterDirectory(absDir, ctx) {
|
|
45418
|
+
const key = await ctx.io.realPath?.(absDir) ?? absDir;
|
|
45419
|
+
if (ctx.visited.has(key)) return false;
|
|
45420
|
+
ctx.visited.add(key);
|
|
45421
|
+
return true;
|
|
45422
|
+
}
|
|
45423
|
+
async function walkDirectory(absDir, entries, inherited, ctx, depth) {
|
|
45424
|
+
if (ctx.budget.exceeded) return;
|
|
45425
|
+
const scopes = await withGitignoreAt(absDir, inherited, ctx.io);
|
|
45426
|
+
for (const name2 of [...entries].sort()) {
|
|
45427
|
+
if (ctx.budget.exceeded) return;
|
|
45428
|
+
if (HARD_SKIP_DIRS.has(name2) || NON_APP_DIRS.has(name2)) continue;
|
|
45429
|
+
const abs = path.join(absDir, name2);
|
|
45430
|
+
if (hasSourceExtension(name2)) {
|
|
45431
|
+
if (NON_APP_FILE.test(name2)) continue;
|
|
45432
|
+
if (isIgnored(abs, false, scopes)) continue;
|
|
45433
|
+
const content = await readSourceFile(abs, ctx.io);
|
|
45434
|
+
if (content === null) continue;
|
|
45435
|
+
if (content.length > MAX_FILE_CHARS) {
|
|
45436
|
+
ctx.budget.exceeded = true;
|
|
45437
|
+
return;
|
|
45438
|
+
}
|
|
45439
|
+
ctx.budget.filesRead += 1;
|
|
45440
|
+
if (ctx.budget.filesRead > MAX_FILES) {
|
|
45441
|
+
ctx.budget.exceeded = true;
|
|
45442
|
+
return;
|
|
45443
|
+
}
|
|
45444
|
+
scanSource(content, projectRelative(ctx.projectPath, abs), ctx.found);
|
|
45445
|
+
continue;
|
|
45446
|
+
}
|
|
45447
|
+
if (isIgnored(abs, true, scopes)) continue;
|
|
45448
|
+
const children = await ctx.io.readDir(abs);
|
|
45449
|
+
if (children.length === 0) continue;
|
|
45450
|
+
if (!await enterDirectory(abs, ctx)) continue;
|
|
45451
|
+
if (depth + 1 > MAX_DEPTH) {
|
|
45452
|
+
ctx.budget.exceeded = true;
|
|
45453
|
+
return;
|
|
45454
|
+
}
|
|
45455
|
+
await walkDirectory(abs, children, scopes, ctx, depth + 1);
|
|
45456
|
+
}
|
|
45457
|
+
}
|
|
45458
|
+
async function referencedEnvFindings(input, io2) {
|
|
45459
|
+
const apps = input.apps ?? [];
|
|
45460
|
+
if (apps.length === 0) return [];
|
|
45461
|
+
const projectPath = input.projectPath;
|
|
45462
|
+
const budget = { filesRead: 0, exceeded: false };
|
|
45463
|
+
const rootScopes = await withGitignoreAt(projectPath, [], io2);
|
|
45464
|
+
const rootAccounted = await accountedEnvKeys(projectPath, void 0, io2);
|
|
45465
|
+
const findings = [];
|
|
45466
|
+
const seen2 = /* @__PURE__ */ new Set();
|
|
45467
|
+
for (const app of apps) {
|
|
45468
|
+
const absDir = absoluteDir(projectPath, app.path);
|
|
45469
|
+
if (seen2.has(absDir)) continue;
|
|
45470
|
+
seen2.add(absDir);
|
|
45471
|
+
const found = /* @__PURE__ */ new Map();
|
|
45472
|
+
const entries = await io2.readDir(absDir);
|
|
45473
|
+
const ctx = { io: io2, projectPath, budget, found, visited: /* @__PURE__ */ new Set() };
|
|
45474
|
+
await enterDirectory(absDir, ctx);
|
|
45475
|
+
await walkDirectory(absDir, entries, rootScopes, ctx, 0);
|
|
45476
|
+
if (budget.exceeded) return [];
|
|
45477
|
+
if (found.size === 0) continue;
|
|
45478
|
+
const accounted = new Set(rootAccounted);
|
|
45479
|
+
for (const key of await accountedEnvKeys(absDir, app.type, io2)) accounted.add(key);
|
|
45480
|
+
const missing = [...found.keys()].filter((key) => !accounted.has(key)).sort();
|
|
45481
|
+
if (missing.length === 0) continue;
|
|
45482
|
+
const appRel = projectRelative(projectPath, app.path);
|
|
45483
|
+
const count = missing.length;
|
|
45484
|
+
const plural2 = count === 1 ? "" : "s";
|
|
45485
|
+
findings.push({
|
|
45486
|
+
id: `env.referenced:${rootLabel(appRel)}`,
|
|
45487
|
+
severity: "info",
|
|
45488
|
+
category: "env",
|
|
45489
|
+
title: `${count} env var${plural2} referenced in ${rootPhrase(appRel)} ${count === 1 ? "is" : "are"} declared nowhere`,
|
|
45490
|
+
detail: `Supbuddy found ${count === 1 ? "this name" : "these names"} in the source, and no env file it loads sets ${count === 1 ? "it" : "them"} and no example file declares ${count === 1 ? "it" : "them"}. This is INFERRED from source text, so it can be wrong — a name only a deployment platform supplies, a commented-out read and dead code all look the same to it. Each name below carries the first file and line that reads it, so check ${count === 1 ? "it" : "them"} before acting. Supbuddy has no value to supply for ${count === 1 ? "it" : "them"}, so there is nothing here to apply.`,
|
|
45491
|
+
evidence: missing.map((key) => `${key} (${found.get(key)})`),
|
|
45492
|
+
goToTab: "apps"
|
|
45493
|
+
});
|
|
45494
|
+
}
|
|
45495
|
+
return findings;
|
|
45496
|
+
}
|
|
45497
|
+
const MAX_RETAINED_JOBS$1 = 50;
|
|
45498
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
45499
|
+
const activeByRoot = /* @__PURE__ */ new Map();
|
|
45500
|
+
const retiredOrder = [];
|
|
45501
|
+
function snapshot(job) {
|
|
45502
|
+
return { ...job, argv: [...job.argv] };
|
|
45503
|
+
}
|
|
45504
|
+
function rootKeyFor(projectId, rootPath) {
|
|
45505
|
+
return `${projectId}:${rootPath}`;
|
|
45506
|
+
}
|
|
45507
|
+
function retire(jobId) {
|
|
45508
|
+
retiredOrder.push(jobId);
|
|
45509
|
+
while (retiredOrder.length > MAX_RETAINED_JOBS$1) {
|
|
45510
|
+
jobs.delete(retiredOrder.shift());
|
|
45511
|
+
}
|
|
45512
|
+
}
|
|
45513
|
+
function finish$1(record2, io2, clock, outcome) {
|
|
45514
|
+
if (record2.job.state !== "running") return;
|
|
45515
|
+
record2.job.state = outcome.state;
|
|
45516
|
+
record2.job.endedAt = clock();
|
|
45517
|
+
if (outcome.exitCode !== void 0) record2.job.exitCode = outcome.exitCode;
|
|
45518
|
+
if (outcome.error !== void 0) record2.job.error = outcome.error;
|
|
45519
|
+
if (activeByRoot.get(record2.rootKey) === record2.job.id) {
|
|
45520
|
+
activeByRoot.delete(record2.rootKey);
|
|
45521
|
+
}
|
|
45522
|
+
retire(record2.job.id);
|
|
45523
|
+
io2.emit("install:state", snapshot(record2.job));
|
|
45524
|
+
}
|
|
45525
|
+
function startInstall(opts) {
|
|
45526
|
+
const { projectId, projectPath, root, io: io2, now, makeId } = opts;
|
|
45527
|
+
const clock = opts.clock ?? Date.now;
|
|
45528
|
+
const rootKey = rootKeyFor(projectId, root.path);
|
|
45529
|
+
const runningId = activeByRoot.get(rootKey);
|
|
45530
|
+
if (runningId !== void 0) {
|
|
45531
|
+
const running = jobs.get(runningId);
|
|
45532
|
+
if (running) return snapshot(running.job);
|
|
45533
|
+
activeByRoot.delete(rootKey);
|
|
45534
|
+
}
|
|
45535
|
+
if (root.installArgv.length === 0) {
|
|
45536
|
+
throw new Error(
|
|
45537
|
+
`Cannot install in ${rootPhrase(root.path)}: no package manager was identified there (no packageManager field and no lockfile), so there is no install command to run.`
|
|
45538
|
+
);
|
|
45539
|
+
}
|
|
45540
|
+
if (!root.managerAvailable) {
|
|
45541
|
+
throw new Error(
|
|
45542
|
+
`Cannot install in ${rootPhrase(root.path)}: "${root.manager}" is not installed or not found in PATH.`
|
|
45543
|
+
);
|
|
45544
|
+
}
|
|
45545
|
+
const id = makeId();
|
|
45546
|
+
const record2 = {
|
|
45547
|
+
job: {
|
|
45548
|
+
id,
|
|
45549
|
+
projectId,
|
|
45550
|
+
rootPath: root.path,
|
|
45551
|
+
argv: [...root.installArgv],
|
|
45552
|
+
state: "running",
|
|
45553
|
+
startedAt: now
|
|
45554
|
+
},
|
|
45555
|
+
rootKey,
|
|
45556
|
+
spawned: null,
|
|
45557
|
+
cancelRequested: false
|
|
45558
|
+
};
|
|
45559
|
+
jobs.set(id, record2);
|
|
45560
|
+
activeByRoot.set(rootKey, id);
|
|
45561
|
+
try {
|
|
45562
|
+
record2.spawned = spawnProjectCommand({
|
|
45563
|
+
// The install directory travels HERE, never in argv: `shell: true` re-parses the
|
|
45564
|
+
// joined argv through /bin/sh, which would split a path containing a space and
|
|
45565
|
+
// EXECUTE one containing `$(…)`. `installArgv` is a fixed vocabulary for the same
|
|
45566
|
+
// reason.
|
|
45567
|
+
cwd: path.join(projectPath, root.path),
|
|
45568
|
+
argv: record2.job.argv,
|
|
45569
|
+
onLine: (line, stream2) => {
|
|
45570
|
+
io2.emit("install:log", {
|
|
45571
|
+
projectId,
|
|
45572
|
+
jobId: id,
|
|
45573
|
+
line,
|
|
45574
|
+
type: stream2,
|
|
45575
|
+
timestamp: clock()
|
|
45576
|
+
});
|
|
45577
|
+
},
|
|
45578
|
+
onExit: (code, signal) => {
|
|
45579
|
+
if (record2.cancelRequested) {
|
|
45580
|
+
finish$1(record2, io2, clock, { state: "cancelled" });
|
|
45581
|
+
return;
|
|
45582
|
+
}
|
|
45583
|
+
if (code === 0) {
|
|
45584
|
+
finish$1(record2, io2, clock, { state: "success", exitCode: 0 });
|
|
45585
|
+
return;
|
|
45586
|
+
}
|
|
45587
|
+
if (code === null) {
|
|
45588
|
+
finish$1(record2, io2, clock, {
|
|
45589
|
+
state: "failed",
|
|
45590
|
+
error: `Install was terminated by ${signal ?? "a signal"}`
|
|
45591
|
+
});
|
|
45592
|
+
return;
|
|
45593
|
+
}
|
|
45594
|
+
finish$1(record2, io2, clock, { state: "failed", exitCode: code });
|
|
45595
|
+
},
|
|
45596
|
+
onError: (error) => {
|
|
45597
|
+
finish$1(record2, io2, clock, { state: "failed", error: error.message });
|
|
45598
|
+
}
|
|
45599
|
+
});
|
|
45600
|
+
} catch (error) {
|
|
45601
|
+
record2.job.state = "failed";
|
|
45602
|
+
jobs.delete(id);
|
|
45603
|
+
if (activeByRoot.get(rootKey) === id) activeByRoot.delete(rootKey);
|
|
45604
|
+
throw error;
|
|
45605
|
+
}
|
|
45606
|
+
io2.emit("install:state", snapshot(record2.job));
|
|
45607
|
+
return snapshot(record2.job);
|
|
45608
|
+
}
|
|
45609
|
+
async function cancelInstall(jobId) {
|
|
45610
|
+
const record2 = jobs.get(jobId);
|
|
45611
|
+
if (!record2) {
|
|
45612
|
+
throw new Error(`Install job "${jobId}" is not known`);
|
|
45613
|
+
}
|
|
45614
|
+
if (record2.job.state !== "running" || !record2.spawned) return;
|
|
45615
|
+
record2.cancelRequested = true;
|
|
45616
|
+
await record2.spawned.stop();
|
|
45617
|
+
}
|
|
45618
|
+
async function cancelInstallsForProject(projectId) {
|
|
45619
|
+
const running = [...jobs.values()].filter((record2) => record2.job.projectId === projectId && record2.job.state === "running").map((record2) => record2.job.id);
|
|
45620
|
+
await Promise.all(
|
|
45621
|
+
running.map(async (id) => {
|
|
45622
|
+
try {
|
|
45623
|
+
await cancelInstall(id);
|
|
45624
|
+
} catch (error) {
|
|
45625
|
+
console.error(
|
|
45626
|
+
`[InstallRunner] Failed to cancel install ${id} for deleted project ${projectId}:`,
|
|
45627
|
+
error.message
|
|
45628
|
+
);
|
|
45629
|
+
}
|
|
45630
|
+
})
|
|
45631
|
+
);
|
|
45632
|
+
return running;
|
|
45633
|
+
}
|
|
45634
|
+
function getInstallJob(jobId) {
|
|
45635
|
+
const record2 = jobs.get(jobId);
|
|
45636
|
+
return record2 ? snapshot(record2.job) : void 0;
|
|
45637
|
+
}
|
|
45638
|
+
function getActiveInstallForProject(projectId) {
|
|
45639
|
+
const running = [...jobs.values()].map((record2) => record2.job).filter((job) => job.projectId === projectId && job.state === "running").sort((a, b) => a.startedAt - b.startedAt || a.rootPath.localeCompare(b.rootPath));
|
|
45640
|
+
return running.length > 0 ? snapshot(running[0]) : void 0;
|
|
45641
|
+
}
|
|
45642
|
+
function findingsMootedByInstall(job) {
|
|
45643
|
+
if (!job || job.state !== "running") return /* @__PURE__ */ new Set();
|
|
45644
|
+
const label = rootLabel(job.rootPath);
|
|
45645
|
+
return /* @__PURE__ */ new Set([`deps.missing:${label}`, `deps.stale:${label}`]);
|
|
45646
|
+
}
|
|
45647
|
+
const noErrorFinding = (findings) => findings.every((finding) => finding.severity !== "error");
|
|
45648
|
+
async function scanForVerdict(input, io2) {
|
|
45649
|
+
const apps = input.apps ?? [];
|
|
45650
|
+
const { installRoots, findings: depFindings } = await detectDependencyReadiness(
|
|
45651
|
+
{
|
|
45652
|
+
projectId: input.projectId,
|
|
45653
|
+
projectPath: input.projectPath,
|
|
45654
|
+
// See the header: the scanner's absolute paths would silently enumerate nothing.
|
|
45655
|
+
apps: apps.map((app) => projectRelative(input.projectPath, app.path))
|
|
45656
|
+
},
|
|
45657
|
+
io2
|
|
45658
|
+
);
|
|
45659
|
+
const ownedFindings = ownedEnvFindings({
|
|
45660
|
+
projectId: input.projectId,
|
|
45661
|
+
projectPath: input.projectPath,
|
|
45662
|
+
apps,
|
|
45663
|
+
drift: input.drift
|
|
45664
|
+
});
|
|
45665
|
+
return { installRoots, findings: [...depFindings, ...ownedFindings] };
|
|
45666
|
+
}
|
|
45667
|
+
async function projectIsReady(input, io2) {
|
|
45668
|
+
const { findings } = await scanForVerdict(input, io2);
|
|
45669
|
+
return noErrorFinding(findings);
|
|
45670
|
+
}
|
|
45671
|
+
async function composeReadiness(input, io2) {
|
|
45672
|
+
const apps = input.apps ?? [];
|
|
45673
|
+
const { installRoots, findings: verdictFindings } = await scanForVerdict(input, io2);
|
|
45674
|
+
const declaredFindings = await declaredEnvFindings(
|
|
45675
|
+
{ projectId: input.projectId, projectPath: input.projectPath, apps },
|
|
45676
|
+
io2
|
|
45677
|
+
);
|
|
45678
|
+
const referencedFindings = await referencedEnvFindings(
|
|
45679
|
+
{ projectId: input.projectId, projectPath: input.projectPath, apps },
|
|
45680
|
+
io2
|
|
45681
|
+
);
|
|
45682
|
+
const detected = [
|
|
45683
|
+
...verdictFindings,
|
|
45684
|
+
...declaredFindings,
|
|
45685
|
+
...referencedFindings
|
|
45686
|
+
];
|
|
45687
|
+
const ok = noErrorFinding(detected);
|
|
45688
|
+
const activeInstall = input.activeInstall && input.activeInstall.projectId === input.projectId ? input.activeInstall : void 0;
|
|
45689
|
+
const mooted = findingsMootedByInstall(activeInstall);
|
|
45690
|
+
const findings = mooted.size === 0 ? detected : detected.filter((f2) => !mooted.has(f2.id));
|
|
45691
|
+
return {
|
|
45692
|
+
projectId: input.projectId,
|
|
45693
|
+
checkedAt: input.now,
|
|
45694
|
+
ok,
|
|
45695
|
+
findings,
|
|
45696
|
+
installRoots,
|
|
45697
|
+
// Spread rather than assigned: an explicit `activeInstall: undefined` would put the
|
|
45698
|
+
// key on the wire, and `get_project_readiness`'s JSON would then carry a null field
|
|
45699
|
+
// that reads as "checked, none running" where the absent key reads the same and
|
|
45700
|
+
// costs nothing.
|
|
45701
|
+
...activeInstall ? { activeInstall } : {}
|
|
45702
|
+
};
|
|
45703
|
+
}
|
|
45704
|
+
const index$3 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
45705
|
+
__proto__: null,
|
|
45706
|
+
ALL_ENV_PREFIXES,
|
|
45707
|
+
cancelInstall,
|
|
45708
|
+
cancelInstallsForProject,
|
|
45709
|
+
composeReadiness,
|
|
45710
|
+
declaredEnvFindings,
|
|
45711
|
+
detectDependencyReadiness,
|
|
45712
|
+
detectInstallState,
|
|
45713
|
+
detectNodeLinkerAt,
|
|
45714
|
+
detectPackageManagerAt,
|
|
45715
|
+
enumerateInstallRoots,
|
|
45716
|
+
envFileNameForType,
|
|
45717
|
+
envPrefixForType,
|
|
45718
|
+
getActiveInstallForProject,
|
|
45719
|
+
getInstallJob,
|
|
45720
|
+
installArgvFor,
|
|
45721
|
+
lockfilePathsFor,
|
|
45722
|
+
nodeReadinessIo,
|
|
45723
|
+
normalizeDirPath,
|
|
45724
|
+
ownedEnvFindings,
|
|
45725
|
+
parsePackageManagerField,
|
|
45726
|
+
projectIsReady,
|
|
45727
|
+
projectRelative,
|
|
45728
|
+
receiptPathFor,
|
|
45729
|
+
referencedEnvFindings,
|
|
45730
|
+
rootLabel,
|
|
45731
|
+
rootPhrase,
|
|
45732
|
+
startInstall
|
|
45733
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
44480
45734
|
const TTL_DAYS_DEFAULT = 7;
|
|
44481
45735
|
function expiresAt(now = /* @__PURE__ */ new Date()) {
|
|
44482
45736
|
const ttl = (useStore.getState().settings.mcp?.trash_ttl_days ?? TTL_DAYS_DEFAULT) * 24 * 60 * 60 * 1e3;
|
|
@@ -44536,6 +45790,12 @@ async function trashProject(id, deletedBy) {
|
|
|
44536
45790
|
related: [project.id]
|
|
44537
45791
|
});
|
|
44538
45792
|
}
|
|
45793
|
+
await cancelInstallsForProject(id);
|
|
45794
|
+
const io2 = globalThis.io ?? { emit: () => {
|
|
45795
|
+
} };
|
|
45796
|
+
await stopAllProjectScripts(id, io2).catch(
|
|
45797
|
+
(err) => console.error(`[mcp/trash] Script cleanup failed for trashed project ${id}:`, err)
|
|
45798
|
+
);
|
|
44539
45799
|
return project;
|
|
44540
45800
|
}
|
|
44541
45801
|
async function restoreProject(id, restoreChildren = true) {
|
|
@@ -44586,9 +45846,14 @@ function sweepTrash(now = /* @__PURE__ */ new Date()) {
|
|
|
44586
45846
|
return removed;
|
|
44587
45847
|
}
|
|
44588
45848
|
async function applyMappingChangeSideEffects(reason = "mcp:mapping-change") {
|
|
44589
|
-
if (!isCaddyRunning()) return;
|
|
45849
|
+
if (!isCaddyRunning()) return {};
|
|
44590
45850
|
await reloadCaddyConfig().catch((err) => console.error("[mcp] Caddy reload after mapping change failed:", err));
|
|
44591
|
-
await globalThis.reapplyDnsResolversIfDrifted?.(reason);
|
|
45851
|
+
const r = await globalThis.reapplyDnsResolversIfDrifted?.(reason);
|
|
45852
|
+
if (!r || r.ok !== false) return {};
|
|
45853
|
+
const suffixes = (r.missing ?? []).join(", ") || "The suffix";
|
|
45854
|
+
const resolver_warning = `The mapping was saved, but the OS resolver files were NOT updated (${r.error ?? r.outcome}). ${suffixes} will not resolve until /etc/resolver/<suffix> exists. Re-run \`supbuddy proxy restart\` and approve the password prompt.`;
|
|
45855
|
+
console.warn(`[mcp] ${resolver_warning}`);
|
|
45856
|
+
return { resolver_warning };
|
|
44592
45857
|
}
|
|
44593
45858
|
const mappingTools = {
|
|
44594
45859
|
create_mapping: async (a) => {
|
|
@@ -44598,35 +45863,35 @@ const mappingTools = {
|
|
|
44598
45863
|
if (a.enabled === false) useStore.getState().updateMapping(m.id, { enabled: false });
|
|
44599
45864
|
if (a.https === false) useStore.getState().updateMapping(m.id, { https: false });
|
|
44600
45865
|
const fresh = useStore.getState().getMapping(m.id);
|
|
44601
|
-
await applyMappingChangeSideEffects();
|
|
44602
|
-
return { ...fresh, __reversible_via: { tool: "delete_mapping", args: { id_or_domain: fresh.id } } };
|
|
45866
|
+
const fx = await applyMappingChangeSideEffects();
|
|
45867
|
+
return { ...fresh, ...fx, __reversible_via: { tool: "delete_mapping", args: { id_or_domain: fresh.id } } };
|
|
44603
45868
|
},
|
|
44604
45869
|
update_mapping: async (a) => {
|
|
44605
45870
|
const m = useStore.getState().getMapping(a.id);
|
|
44606
45871
|
if (!m) throw new McpError("plan_not_found", `Mapping ${a.id} not found`);
|
|
44607
45872
|
const before = { ...m };
|
|
44608
45873
|
useStore.getState().updateMapping(a.id, a.patch);
|
|
44609
|
-
await applyMappingChangeSideEffects();
|
|
44610
|
-
return { ...useStore.getState().getMapping(a.id), __reversible_via: { tool: "update_mapping", args: { id: a.id, patch: before } } };
|
|
45874
|
+
const fx = await applyMappingChangeSideEffects();
|
|
45875
|
+
return { ...useStore.getState().getMapping(a.id), ...fx, __reversible_via: { tool: "update_mapping", args: { id: a.id, patch: before } } };
|
|
44611
45876
|
},
|
|
44612
45877
|
enable_mapping: async (a) => {
|
|
44613
45878
|
const m = useStore.getState().getMapping(a.id);
|
|
44614
45879
|
if (!m) throw new McpError("plan_not_found", `Mapping ${a.id} not found`);
|
|
44615
45880
|
useStore.getState().updateMapping(a.id, { enabled: true });
|
|
44616
|
-
await applyMappingChangeSideEffects();
|
|
44617
|
-
return { ...useStore.getState().getMapping(a.id), __reversible_via: { tool: "disable_mapping", args: { id: a.id } } };
|
|
45881
|
+
const fx = await applyMappingChangeSideEffects();
|
|
45882
|
+
return { ...useStore.getState().getMapping(a.id), ...fx, __reversible_via: { tool: "disable_mapping", args: { id: a.id } } };
|
|
44618
45883
|
},
|
|
44619
45884
|
disable_mapping: async (a) => {
|
|
44620
45885
|
const m = useStore.getState().getMapping(a.id);
|
|
44621
45886
|
if (!m) throw new McpError("plan_not_found", `Mapping ${a.id} not found`);
|
|
44622
45887
|
useStore.getState().updateMapping(a.id, { enabled: false });
|
|
44623
|
-
await applyMappingChangeSideEffects();
|
|
44624
|
-
return { ...useStore.getState().getMapping(a.id), __reversible_via: { tool: "enable_mapping", args: { id: a.id } } };
|
|
45888
|
+
const fx = await applyMappingChangeSideEffects();
|
|
45889
|
+
return { ...useStore.getState().getMapping(a.id), ...fx, __reversible_via: { tool: "enable_mapping", args: { id: a.id } } };
|
|
44625
45890
|
},
|
|
44626
45891
|
restore_mapping: async (a) => {
|
|
44627
45892
|
const m = await restoreMapping(a.id);
|
|
44628
|
-
await applyMappingChangeSideEffects("mcp:restore-mapping");
|
|
44629
|
-
return { ...m, __reversible_via: { tool: "delete_mapping", args: { id_or_domain: m.id } } };
|
|
45893
|
+
const fx = await applyMappingChangeSideEffects("mcp:restore-mapping");
|
|
45894
|
+
return { ...m, ...fx, __reversible_via: { tool: "delete_mapping", args: { id_or_domain: m.id } } };
|
|
44630
45895
|
}
|
|
44631
45896
|
};
|
|
44632
45897
|
const APP_ROLES = [
|
|
@@ -44932,39 +46197,90 @@ const projectTools = {
|
|
|
44932
46197
|
return useStore.getState().settings;
|
|
44933
46198
|
}
|
|
44934
46199
|
};
|
|
44935
|
-
|
|
44936
|
-
|
|
46200
|
+
const KNOWN_MANAGERS = PACKAGE_MANAGER_NAMES;
|
|
46201
|
+
function runManagerRefusal(projectId) {
|
|
46202
|
+
return `Cannot determine the package manager for project "${projectId}": its package.json declares no "packageManager" field and the project root holds no lockfile. Supbuddy will not guess npm here — readiness reports this repo as undetermined too. Add a "packageManager" field, or install once to create a lockfile, then rescan the project.`;
|
|
46203
|
+
}
|
|
46204
|
+
async function resolveRunPackageManager(input, io2) {
|
|
46205
|
+
const recorded = input.recorded;
|
|
46206
|
+
if (typeof recorded === "string" && KNOWN_MANAGERS.includes(recorded)) {
|
|
46207
|
+
return { manager: recorded, source: "project-record" };
|
|
46208
|
+
}
|
|
46209
|
+
const reader2 = io2 ?? (await __vitePreload(async () => {
|
|
46210
|
+
const { nodeReadinessIo: nodeReadinessIo2 } = await Promise.resolve().then(() => io$1);
|
|
46211
|
+
return { nodeReadinessIo: nodeReadinessIo2 };
|
|
46212
|
+
}, false ? __VITE_PRELOAD__ : void 0)).nodeReadinessIo();
|
|
46213
|
+
const detected = await detectPackageManagerAt(input.projectPath, reader2);
|
|
46214
|
+
if (detected) return { manager: detected.manager, source: "detected" };
|
|
46215
|
+
return { manager: null, reason: runManagerRefusal(input.projectId) };
|
|
46216
|
+
}
|
|
46217
|
+
const runManager = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
46218
|
+
__proto__: null,
|
|
46219
|
+
resolveRunPackageManager,
|
|
46220
|
+
runManagerRefusal
|
|
46221
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
46222
|
+
function devCommand(manager) {
|
|
46223
|
+
switch (manager) {
|
|
44937
46224
|
case "pnpm":
|
|
44938
46225
|
return ["pnpm", "dev"];
|
|
44939
46226
|
case "yarn":
|
|
44940
46227
|
return ["yarn", "dev"];
|
|
44941
|
-
|
|
46228
|
+
case "bun":
|
|
46229
|
+
return ["bun", "run", "dev"];
|
|
46230
|
+
case "npm":
|
|
44942
46231
|
return ["npm", "run", "dev"];
|
|
44943
46232
|
}
|
|
44944
46233
|
}
|
|
46234
|
+
const NOTHING_TO_READ = {
|
|
46235
|
+
readFile: async () => null,
|
|
46236
|
+
readDir: async () => [],
|
|
46237
|
+
statMtimeMs: async () => null,
|
|
46238
|
+
hasBinary: async () => false
|
|
46239
|
+
};
|
|
44945
46240
|
function runnerId(name2, index2) {
|
|
44946
46241
|
const slug = name2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
|
|
44947
46242
|
return slug || `app-${index2 + 1}`;
|
|
44948
46243
|
}
|
|
44949
|
-
function deriveRunners(project, approvedSha) {
|
|
46244
|
+
async function deriveRunners(project, approvedSha, io2) {
|
|
44950
46245
|
const apps = project.apps ?? [];
|
|
44951
46246
|
const runners = [];
|
|
44952
46247
|
const mappings = [];
|
|
44953
46248
|
const skipped = [];
|
|
44954
46249
|
const usedPorts = /* @__PURE__ */ new Set();
|
|
44955
46250
|
const usedIds = /* @__PURE__ */ new Set();
|
|
44956
|
-
|
|
46251
|
+
const projectPath = project.path ?? ".";
|
|
46252
|
+
const reader2 = project.path ? io2 : NOTHING_TO_READ;
|
|
46253
|
+
const resolve = async (recorded) => {
|
|
46254
|
+
try {
|
|
46255
|
+
return await resolveRunPackageManager({ projectId: project.id, projectPath, recorded }, reader2);
|
|
46256
|
+
} catch (err) {
|
|
46257
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
46258
|
+
return { manager: null, reason: `Could not read ${projectPath} to determine its package manager: ${detail}` };
|
|
46259
|
+
}
|
|
46260
|
+
};
|
|
46261
|
+
const workspace = await resolve(project.packageManager);
|
|
46262
|
+
const managerFor = async (app) => {
|
|
46263
|
+
if (!app.packageManager) return workspace;
|
|
46264
|
+
const own = await resolve(app.packageManager);
|
|
46265
|
+
return own.manager ? own : workspace;
|
|
46266
|
+
};
|
|
46267
|
+
for (const [i2, app] of apps.entries()) {
|
|
44957
46268
|
if (!app.port) {
|
|
44958
46269
|
skipped.push({ name: app.name, reason: "no port detected" });
|
|
44959
|
-
|
|
46270
|
+
continue;
|
|
44960
46271
|
}
|
|
44961
46272
|
if (app.port === 8080) {
|
|
44962
46273
|
skipped.push({ name: app.name, reason: "port 8080 is the IDE" });
|
|
44963
|
-
|
|
46274
|
+
continue;
|
|
44964
46275
|
}
|
|
44965
46276
|
if (usedPorts.has(app.port)) {
|
|
44966
46277
|
skipped.push({ name: app.name, reason: `port ${app.port} already taken by another app` });
|
|
44967
|
-
|
|
46278
|
+
continue;
|
|
46279
|
+
}
|
|
46280
|
+
const manager = await managerFor(app);
|
|
46281
|
+
if (!manager.manager) {
|
|
46282
|
+
skipped.push({ name: app.name, reason: manager.reason });
|
|
46283
|
+
continue;
|
|
44968
46284
|
}
|
|
44969
46285
|
let id = runnerId(app.name, i2);
|
|
44970
46286
|
if (usedIds.has(id)) id = `${id}-${i2 + 1}`;
|
|
@@ -44972,7 +46288,7 @@ function deriveRunners(project, approvedSha) {
|
|
|
44972
46288
|
usedIds.add(id);
|
|
44973
46289
|
runners.push({
|
|
44974
46290
|
id,
|
|
44975
|
-
command: devCommand(
|
|
46291
|
+
command: devCommand(manager.manager),
|
|
44976
46292
|
// Relative to the workspace root — this is what makes a monorepo's several apps addressable.
|
|
44977
46293
|
// A leading './' or absolute path would escape or confuse the supervisor's cwd check.
|
|
44978
46294
|
...app.path && app.path !== "." ? { cwd: app.path.replace(/^\.\//, "") } : {},
|
|
@@ -44980,7 +46296,7 @@ function deriveRunners(project, approvedSha) {
|
|
|
44980
46296
|
...approvedSha ? { autostart: { approvedSha } } : {}
|
|
44981
46297
|
});
|
|
44982
46298
|
mappings.push({ subdomain: id, port: app.port, runner: id, visibility: "private" });
|
|
44983
|
-
}
|
|
46299
|
+
}
|
|
44984
46300
|
return { runners, mappings, skipped };
|
|
44985
46301
|
}
|
|
44986
46302
|
const execAsync$7 = util$1.promisify(child_process.exec);
|
|
@@ -45696,7 +47012,7 @@ async function resolveAndPush(cfg, session, project, input, deps = {}) {
|
|
|
45696
47012
|
}
|
|
45697
47013
|
}
|
|
45698
47014
|
const approvedSha = project.path ? await resolveHeadSha(project.path) : null;
|
|
45699
|
-
const derived = deriveRunners(project, approvedSha ?? void 0);
|
|
47015
|
+
const derived = await deriveRunners(project, approvedSha ?? void 0);
|
|
45700
47016
|
if (derived.skipped.length > 0) {
|
|
45701
47017
|
console.warn(
|
|
45702
47018
|
`[cloud] ${derived.skipped.length} app(s) will not run in the cloud: ` + derived.skipped.map((sk) => `${sk.name} (${sk.reason})`).join(", ")
|
|
@@ -45781,6 +47097,216 @@ async function daemonCloudTeardownProject(projectId) {
|
|
|
45781
47097
|
useStore.getState().updateProject(projectId, { cloud: void 0 });
|
|
45782
47098
|
return { ok: true, stackId: link.stackId };
|
|
45783
47099
|
}
|
|
47100
|
+
const installEvents = new require$$0$1.EventEmitter();
|
|
47101
|
+
installEvents.setMaxListeners(100);
|
|
47102
|
+
const MAX_LINES_PER_JOB = 1e3;
|
|
47103
|
+
const MAX_RETAINED_JOBS = 50;
|
|
47104
|
+
const buffers = /* @__PURE__ */ new Map();
|
|
47105
|
+
function record(event) {
|
|
47106
|
+
let buffer = buffers.get(event.jobId);
|
|
47107
|
+
if (!buffer) {
|
|
47108
|
+
buffer = [];
|
|
47109
|
+
buffers.set(event.jobId, buffer);
|
|
47110
|
+
while (buffers.size > MAX_RETAINED_JOBS) {
|
|
47111
|
+
const oldest = buffers.keys().next().value;
|
|
47112
|
+
if (oldest === void 0) break;
|
|
47113
|
+
buffers.delete(oldest);
|
|
47114
|
+
}
|
|
47115
|
+
}
|
|
47116
|
+
buffer.push(event);
|
|
47117
|
+
if (buffer.length > MAX_LINES_PER_JOB) buffer.shift();
|
|
47118
|
+
}
|
|
47119
|
+
function teeInstallEvents(io2) {
|
|
47120
|
+
const target = io2;
|
|
47121
|
+
return {
|
|
47122
|
+
emit(event, payload) {
|
|
47123
|
+
target?.emit?.(event, payload);
|
|
47124
|
+
if (event === "install:log") {
|
|
47125
|
+
record(payload);
|
|
47126
|
+
installEvents.emit("log", payload);
|
|
47127
|
+
} else if (event === "install:state") {
|
|
47128
|
+
installEvents.emit("state", payload);
|
|
47129
|
+
}
|
|
47130
|
+
}
|
|
47131
|
+
};
|
|
47132
|
+
}
|
|
47133
|
+
function getInstallLogEvents(jobId) {
|
|
47134
|
+
return [...buffers.get(jobId) ?? []];
|
|
47135
|
+
}
|
|
47136
|
+
function resolveProject(ref) {
|
|
47137
|
+
const projects = useStore.getState().projects ?? [];
|
|
47138
|
+
let project = projects.find((p) => p.id === ref);
|
|
47139
|
+
if (!project) {
|
|
47140
|
+
const byName = projects.filter((p) => p.name === ref);
|
|
47141
|
+
if (byName.length > 1) {
|
|
47142
|
+
throw new McpError(
|
|
47143
|
+
"invalid_args",
|
|
47144
|
+
`Project "${ref}" is ambiguous — ${byName.length} projects share that name. Pass an id instead (${byName.map((p) => p.id).join(", ")}).`
|
|
47145
|
+
);
|
|
47146
|
+
}
|
|
47147
|
+
project = byName[0];
|
|
47148
|
+
}
|
|
47149
|
+
if (!project) throw new McpError("invalid_args", `Project ${ref} not found`);
|
|
47150
|
+
if (!project.path) {
|
|
47151
|
+
throw new McpError("invalid_args", `Project ${ref} has no path`);
|
|
47152
|
+
}
|
|
47153
|
+
return project;
|
|
47154
|
+
}
|
|
47155
|
+
function readinessFor(project) {
|
|
47156
|
+
return composeReadiness(
|
|
47157
|
+
{
|
|
47158
|
+
projectId: project.id,
|
|
47159
|
+
projectPath: project.path,
|
|
47160
|
+
// Narrowed to the three fields the composer declares. See the header: the paths
|
|
47161
|
+
// are NOT converted here.
|
|
47162
|
+
apps: (project.apps ?? []).map((app) => ({
|
|
47163
|
+
name: app.name,
|
|
47164
|
+
path: app.path,
|
|
47165
|
+
type: app.type
|
|
47166
|
+
})),
|
|
47167
|
+
// Already classified by `computeConnectionEnvDrift` and persisted; never
|
|
47168
|
+
// recomputed here, because a second classifier is free to disagree with the one
|
|
47169
|
+
// whose verdict the rest of the app is already showing.
|
|
47170
|
+
drift: project.connectionEnvDrift ?? null,
|
|
47171
|
+
now: Date.now(),
|
|
47172
|
+
activeInstall: getActiveInstallForProject(project.id)
|
|
47173
|
+
},
|
|
47174
|
+
nodeReadinessIo()
|
|
47175
|
+
);
|
|
47176
|
+
}
|
|
47177
|
+
function normalizeRootRef(ref) {
|
|
47178
|
+
const trimmed = normalizeDirPath(ref.trim());
|
|
47179
|
+
return trimmed === "." ? "" : trimmed;
|
|
47180
|
+
}
|
|
47181
|
+
async function resolveInstallTarget(a) {
|
|
47182
|
+
const project = resolveProject(a.project);
|
|
47183
|
+
const { installRoots } = await readinessFor(project);
|
|
47184
|
+
if (installRoots.length === 0) {
|
|
47185
|
+
throw new McpError(
|
|
47186
|
+
"invalid_args",
|
|
47187
|
+
`Project "${project.name}" has no install root — no package.json was found at its path or in any of its apps, so there is nothing to install.`
|
|
47188
|
+
);
|
|
47189
|
+
}
|
|
47190
|
+
const available = installRoots.map((root2) => `"${rootLabel(root2.path)}"`).join(", ");
|
|
47191
|
+
if (a.root === void 0) {
|
|
47192
|
+
if (installRoots.length > 1) {
|
|
47193
|
+
throw new McpError(
|
|
47194
|
+
"invalid_args",
|
|
47195
|
+
`Project "${project.name}" has ${installRoots.length} install roots (${available}) — pass \`root\` to name one. Each owns its own lockfile and its own node_modules, so installing the wrong one leaves the right one exactly as broken as it was.`,
|
|
47196
|
+
{ details: { install_roots: installRoots.map((root2) => root2.path) } }
|
|
47197
|
+
);
|
|
47198
|
+
}
|
|
47199
|
+
return { project, root: installRoots[0], cwd: path.join(project.path, installRoots[0].path) };
|
|
47200
|
+
}
|
|
47201
|
+
const wanted = normalizeRootRef(a.root);
|
|
47202
|
+
const root = installRoots.find((candidate) => candidate.path === wanted);
|
|
47203
|
+
if (!root) {
|
|
47204
|
+
throw new McpError(
|
|
47205
|
+
"invalid_args",
|
|
47206
|
+
`"${rootLabel(wanted)}" is not an install root of project "${project.name}". An install root is a directory that owns a lockfile; this project's are: ${available}. Call get_project_readiness to see them.`,
|
|
47207
|
+
{ details: { install_roots: installRoots.map((candidate) => candidate.path) } }
|
|
47208
|
+
);
|
|
47209
|
+
}
|
|
47210
|
+
return { project, root, cwd: path.join(project.path, root.path) };
|
|
47211
|
+
}
|
|
47212
|
+
function assertInstallable(root) {
|
|
47213
|
+
if (root.installArgv.length === 0) {
|
|
47214
|
+
throw new McpError(
|
|
47215
|
+
"invalid_args",
|
|
47216
|
+
`Nothing identifies a package manager for ${rootPhrase(root.path)} — no packageManager field and no lockfile — so there is no install command to run. Supbuddy will not guess one: running the wrong manager writes a stray lockfile into the repo.`,
|
|
47217
|
+
{ details: { reason: "manager_unidentified", root: root.path } }
|
|
47218
|
+
);
|
|
47219
|
+
}
|
|
47220
|
+
if (!root.managerAvailable) {
|
|
47221
|
+
throw new McpError(
|
|
47222
|
+
"invalid_args",
|
|
47223
|
+
`"${root.manager}" is not installed, or is not resolvable on Supbuddy's augmented PATH, so an install in ${rootPhrase(root.path)} would exit 127 having done nothing. Install ${root.manager} (or make it visible to Supbuddy) — Supbuddy does not install package managers.`,
|
|
47224
|
+
{ details: { reason: "manager_unavailable", manager: root.manager, root: root.path } }
|
|
47225
|
+
);
|
|
47226
|
+
}
|
|
47227
|
+
}
|
|
47228
|
+
async function previewInstallDependencies(a) {
|
|
47229
|
+
const { project, root, cwd } = await resolveInstallTarget(a);
|
|
47230
|
+
assertInstallable(root);
|
|
47231
|
+
return {
|
|
47232
|
+
summary: `Run \`${root.installArgv.join(" ")}\` in ${cwd}`,
|
|
47233
|
+
affected_entities: [
|
|
47234
|
+
{
|
|
47235
|
+
kind: "project",
|
|
47236
|
+
id: project.id,
|
|
47237
|
+
description: `${project.name} — install root ${rootLabel(root.path)}`
|
|
47238
|
+
}
|
|
47239
|
+
],
|
|
47240
|
+
side_effects: [
|
|
47241
|
+
`Runs ${root.manager} in ${cwd}`,
|
|
47242
|
+
"Downloads packages and executes their install scripts (postinstall runs arbitrary code)",
|
|
47243
|
+
"Creates or updates node_modules/ there, and may rewrite the lockfile",
|
|
47244
|
+
"Starts in the background and returns a job id — follow it with tail_install_log, stop it with cancel_install"
|
|
47245
|
+
],
|
|
47246
|
+
// An install is not undoable by a tool: the previous node_modules is gone, and
|
|
47247
|
+
// pointing at a "remove node_modules" reversal would be a worse state than either.
|
|
47248
|
+
reversal: null
|
|
47249
|
+
};
|
|
47250
|
+
}
|
|
47251
|
+
async function installStateSnapshot(a) {
|
|
47252
|
+
const key = `install:${a.project}:${a.root === void 0 ? "*" : rootLabel(normalizeRootRef(a.root))}`;
|
|
47253
|
+
try {
|
|
47254
|
+
const { root } = await resolveInstallTarget(a);
|
|
47255
|
+
return {
|
|
47256
|
+
[key]: `${rootLabel(root.path)}|${root.installed}|${root.manager}|${root.installArgv.join(" ")}`
|
|
47257
|
+
};
|
|
47258
|
+
} catch {
|
|
47259
|
+
return { [key]: "__unresolvable__" };
|
|
47260
|
+
}
|
|
47261
|
+
}
|
|
47262
|
+
async function startProjectInstall(a) {
|
|
47263
|
+
const { project, root } = await resolveInstallTarget(a);
|
|
47264
|
+
assertInstallable(root);
|
|
47265
|
+
return startInstall({
|
|
47266
|
+
projectId: project.id,
|
|
47267
|
+
projectPath: project.path,
|
|
47268
|
+
root,
|
|
47269
|
+
// Socket.IO alone cannot be subscribed to in-process, so every emit goes through the
|
|
47270
|
+
// tee that also feeds `tail_install_log` and its replay buffer. `globalThis.io` is
|
|
47271
|
+
// absent in a headless daemon and in tests; the tee tolerates that.
|
|
47272
|
+
io: teeInstallEvents(globalThis.io),
|
|
47273
|
+
now: Date.now(),
|
|
47274
|
+
makeId: () => `install_${crypto$1.randomBytes(8).toString("hex")}`
|
|
47275
|
+
});
|
|
47276
|
+
}
|
|
47277
|
+
const readinessTools = {
|
|
47278
|
+
get_project_readiness: async (a) => {
|
|
47279
|
+
return readinessFor(resolveProject(a.project));
|
|
47280
|
+
},
|
|
47281
|
+
/**
|
|
47282
|
+
* Stop a running install. Not destructive — it removes no data and stages no plan:
|
|
47283
|
+
* the destructive act was starting it, and a caller that can see a runaway install
|
|
47284
|
+
* must be able to stop it in one call.
|
|
47285
|
+
*
|
|
47286
|
+
* A job that has already finished is a no-op rather than an error (a cancel racing a
|
|
47287
|
+
* completion is not a failure); a job id that was never issued is an error, because
|
|
47288
|
+
* that is a caller bug worth seeing.
|
|
47289
|
+
*/
|
|
47290
|
+
cancel_install: async (a) => {
|
|
47291
|
+
const existing = getInstallJob(a.job_id);
|
|
47292
|
+
if (!existing) {
|
|
47293
|
+
throw new McpError(
|
|
47294
|
+
"invalid_args",
|
|
47295
|
+
`Install job "${a.job_id}" is not known. Job ids come from install_dependencies, or from get_project_readiness's activeInstall; the daemon forgets a job once fifty later ones have finished, and remembers none across a restart.`
|
|
47296
|
+
);
|
|
47297
|
+
}
|
|
47298
|
+
await cancelInstall(a.job_id);
|
|
47299
|
+
return getInstallJob(a.job_id) ?? existing;
|
|
47300
|
+
}
|
|
47301
|
+
};
|
|
47302
|
+
const readiness = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
47303
|
+
__proto__: null,
|
|
47304
|
+
installStateSnapshot,
|
|
47305
|
+
previewInstallDependencies,
|
|
47306
|
+
readinessTools,
|
|
47307
|
+
resolveInstallTarget,
|
|
47308
|
+
startProjectInstall
|
|
47309
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
45784
47310
|
const DEFAULT_TTL_MS = 5 * 60 * 1e3;
|
|
45785
47311
|
class PlanStore {
|
|
45786
47312
|
plans = /* @__PURE__ */ new Map();
|
|
@@ -45935,6 +47461,25 @@ function teeSupabaseEvents(io2) {
|
|
|
45935
47461
|
}
|
|
45936
47462
|
};
|
|
45937
47463
|
}
|
|
47464
|
+
async function reapplyThinBlockForTool(projectId, tool) {
|
|
47465
|
+
const project = useStore.getState().getProject(projectId);
|
|
47466
|
+
if (!project) return {};
|
|
47467
|
+
try {
|
|
47468
|
+
const block = await reapplyThinSupabaseBlock(project);
|
|
47469
|
+
if (!block) return {};
|
|
47470
|
+
return {
|
|
47471
|
+
thin_supabase_block: {
|
|
47472
|
+
compose_project_id: block.projectId,
|
|
47473
|
+
config_path: block.configPath,
|
|
47474
|
+
ports: block.ports
|
|
47475
|
+
}
|
|
47476
|
+
};
|
|
47477
|
+
} catch (err) {
|
|
47478
|
+
const thin_supabase_block_warning = `${tool} succeeded, but this thin project's Supabase ports were NOT re-assigned (${err?.message ?? err}). The config.toml still declares the stock 54321-54324 block, which collides with any other Supabase stack on this machine. Switch the project to host and back to thin to allocate a block before starting it.`;
|
|
47479
|
+
console.warn(`[mcp] ${thin_supabase_block_warning}`);
|
|
47480
|
+
return { thin_supabase_block_warning };
|
|
47481
|
+
}
|
|
47482
|
+
}
|
|
45938
47483
|
const PROXY_RESCAN_SETTLE_MS = 2500;
|
|
45939
47484
|
const confirm$3 = new ConfirmBridge();
|
|
45940
47485
|
async function gateModal$3(tool, ctx, summary) {
|
|
@@ -45984,14 +47529,19 @@ const executors = {
|
|
|
45984
47529
|
const m = useStore.getState().mappings.find((x) => x.id === args.id_or_domain || x.domain === args.id_or_domain);
|
|
45985
47530
|
if (!m) throw new McpError("plan_not_found", `Mapping not found`);
|
|
45986
47531
|
const trashed = await trashMapping(m.id, `mcp:${ctx.client_id}`);
|
|
45987
|
-
await applyMappingChangeSideEffects();
|
|
45988
|
-
return { trashed_id: trashed.id };
|
|
47532
|
+
const fx = await applyMappingChangeSideEffects();
|
|
47533
|
+
return { trashed_id: trashed.id, ...fx };
|
|
45989
47534
|
},
|
|
45990
47535
|
cloud_teardown: async (args) => daemonCloudTeardownProject(args.project_id),
|
|
47536
|
+
// STARTS the install and returns the job (spec D4). It deliberately does not await the
|
|
47537
|
+
// child: every surface that can reach this executor times out before a cold install
|
|
47538
|
+
// finishes, and a blocking version would report failure for an install that is running
|
|
47539
|
+
// perfectly well — after which the caller starts a second one on top of it.
|
|
47540
|
+
install_dependencies: async (args) => startProjectInstall(args),
|
|
45991
47541
|
delete_project: async (args, ctx) => {
|
|
45992
47542
|
const p = await trashProject(args.id, `mcp:${ctx.client_id}`);
|
|
45993
|
-
await applyMappingChangeSideEffects("mcp:delete-project");
|
|
45994
|
-
return { trashed_id: p.id };
|
|
47543
|
+
const fx = await applyMappingChangeSideEffects("mcp:delete-project");
|
|
47544
|
+
return { trashed_id: p.id, ...fx };
|
|
45995
47545
|
},
|
|
45996
47546
|
start_proxy: async () => {
|
|
45997
47547
|
const { startCaddyServer: startCaddyServer2 } = await __vitePreload(async () => {
|
|
@@ -46233,6 +47783,7 @@ const executors = {
|
|
|
46233
47783
|
supabasePath: rel || void 0,
|
|
46234
47784
|
supabaseProjectId: newSupabaseProjectId ?? void 0
|
|
46235
47785
|
});
|
|
47786
|
+
const thin = await reapplyThinBlockForTool(args.project_id, "set_supabase_config_path");
|
|
46236
47787
|
try {
|
|
46237
47788
|
const settings = useStore.getState().settings;
|
|
46238
47789
|
const scan = await scanProject2(project.path, {
|
|
@@ -46254,7 +47805,8 @@ const executors = {
|
|
|
46254
47805
|
new_supabase_path: rel || null,
|
|
46255
47806
|
old_supabase_project_id: oldSupabaseProjectId,
|
|
46256
47807
|
new_supabase_project_id: newSupabaseProjectId,
|
|
46257
|
-
orphaned_previous_stack
|
|
47808
|
+
orphaned_previous_stack,
|
|
47809
|
+
...thin
|
|
46258
47810
|
};
|
|
46259
47811
|
},
|
|
46260
47812
|
write_supabase_config: async (args) => {
|
|
@@ -46634,11 +48186,13 @@ async function previewFor(tool, args) {
|
|
|
46634
48186
|
reversal: null
|
|
46635
48187
|
};
|
|
46636
48188
|
}
|
|
48189
|
+
case "install_dependencies":
|
|
48190
|
+
return previewInstallDependencies(args);
|
|
46637
48191
|
default:
|
|
46638
48192
|
return { summary: tool, affected_entities: [], side_effects: [], reversal: null };
|
|
46639
48193
|
}
|
|
46640
48194
|
}
|
|
46641
|
-
function snapshotFor(tool, args) {
|
|
48195
|
+
async function snapshotFor(tool, args) {
|
|
46642
48196
|
switch (tool) {
|
|
46643
48197
|
case "delete_mapping": {
|
|
46644
48198
|
const m = useStore.getState().mappings.find((x) => x.id === args.id_or_domain || x.domain === args.id_or_domain);
|
|
@@ -46661,6 +48215,8 @@ function snapshotFor(tool, args) {
|
|
|
46661
48215
|
"wipe:mappings": s.mappings.map((m) => m.id).sort().join(",")
|
|
46662
48216
|
};
|
|
46663
48217
|
}
|
|
48218
|
+
case "install_dependencies":
|
|
48219
|
+
return installStateSnapshot(args);
|
|
46664
48220
|
default:
|
|
46665
48221
|
return {};
|
|
46666
48222
|
}
|
|
@@ -46689,6 +48245,7 @@ const DESTRUCTIVE_TOOLS = [
|
|
|
46689
48245
|
"force_recreate_supabase",
|
|
46690
48246
|
"export_bundle",
|
|
46691
48247
|
"import_bundle",
|
|
48248
|
+
"install_dependencies",
|
|
46692
48249
|
"cleanup_isolation",
|
|
46693
48250
|
"cloud_teardown",
|
|
46694
48251
|
"system_wipe"
|
|
@@ -46705,7 +48262,7 @@ for (const tool of DESTRUCTIVE_TOOLS) {
|
|
|
46705
48262
|
args,
|
|
46706
48263
|
required_feature: MCP_TOOL_FEATURES[tool] ?? "pro_writes",
|
|
46707
48264
|
preview: await previewFor(tool, args),
|
|
46708
|
-
state_snapshot: snapshotFor(tool, args)
|
|
48265
|
+
state_snapshot: await snapshotFor(tool, args)
|
|
46709
48266
|
});
|
|
46710
48267
|
return { ...plan2, can_apply: true, __apply_via: { tool: "apply", args: { plan_id: plan2.id } } };
|
|
46711
48268
|
};
|
|
@@ -46715,7 +48272,7 @@ const applyTools = {
|
|
|
46715
48272
|
const store2 = getPlanStore();
|
|
46716
48273
|
const p = store2.get(args.plan_id);
|
|
46717
48274
|
if (!p) throw new McpError("plan_not_found", `Plan ${args.plan_id} not found`);
|
|
46718
|
-
const current = snapshotFor(p.tool, p.args);
|
|
48275
|
+
const current = await snapshotFor(p.tool, p.args);
|
|
46719
48276
|
const updated = await store2.applyWithSnapshotCheck(args.plan_id, ctx.client_id, current, () => {
|
|
46720
48277
|
return withApplyTimeout(executors[p.tool](p.args, ctx), p.tool);
|
|
46721
48278
|
});
|
|
@@ -47207,6 +48764,15 @@ function requireProjectAndScript(projectId, scriptName) {
|
|
|
47207
48764
|
}
|
|
47208
48765
|
return { project, script };
|
|
47209
48766
|
}
|
|
48767
|
+
async function requireRunManager(project) {
|
|
48768
|
+
const resolved = await resolveRunPackageManager({
|
|
48769
|
+
projectId: project.id,
|
|
48770
|
+
projectPath: project.path,
|
|
48771
|
+
recorded: project.packageManager
|
|
48772
|
+
});
|
|
48773
|
+
if (resolved.manager === null) throw new McpError("invalid_args", resolved.reason);
|
|
48774
|
+
return resolved.manager;
|
|
48775
|
+
}
|
|
47210
48776
|
const scriptTools = {
|
|
47211
48777
|
// Returns the project's scripts array (name, command, status, bookmarked, etc.)
|
|
47212
48778
|
list_scripts: async (a) => {
|
|
@@ -47219,13 +48785,14 @@ const scriptTools = {
|
|
|
47219
48785
|
// Start a named script — mirrors the scripts:start IPC handler in worker/index.ts
|
|
47220
48786
|
start_script: async (a) => {
|
|
47221
48787
|
const { project, script } = requireProjectAndScript(a.project_id, a.script);
|
|
48788
|
+
const packageManager = await requireRunManager(project);
|
|
47222
48789
|
const io2 = getIo();
|
|
47223
48790
|
await startScript(
|
|
47224
48791
|
a.project_id,
|
|
47225
48792
|
project.path,
|
|
47226
48793
|
a.script,
|
|
47227
48794
|
script.command,
|
|
47228
|
-
|
|
48795
|
+
packageManager,
|
|
47229
48796
|
io2
|
|
47230
48797
|
);
|
|
47231
48798
|
return { ok: true, project_id: a.project_id, script: a.script };
|
|
@@ -47240,13 +48807,14 @@ const scriptTools = {
|
|
|
47240
48807
|
// Restart a named script — mirrors the scripts:restart IPC handler
|
|
47241
48808
|
restart_script: async (a) => {
|
|
47242
48809
|
const { project, script } = requireProjectAndScript(a.project_id, a.script);
|
|
48810
|
+
const packageManager = await requireRunManager(project);
|
|
47243
48811
|
const io2 = getIo();
|
|
47244
48812
|
await restartScript(
|
|
47245
48813
|
a.project_id,
|
|
47246
48814
|
project.path,
|
|
47247
48815
|
a.script,
|
|
47248
48816
|
script.command,
|
|
47249
|
-
|
|
48817
|
+
packageManager,
|
|
47250
48818
|
io2
|
|
47251
48819
|
);
|
|
47252
48820
|
return { ok: true, project_id: a.project_id, script: a.script };
|
|
@@ -47294,7 +48862,8 @@ const supabaseExtrasTools = {
|
|
|
47294
48862
|
init_supabase: async (args) => {
|
|
47295
48863
|
const project = requireProject$1(args.project_id);
|
|
47296
48864
|
await execAsync$6("supabase init", { cwd: project.path, timeout: 3e4 });
|
|
47297
|
-
|
|
48865
|
+
const thin = await reapplyThinBlockForTool(args.project_id, "init_supabase");
|
|
48866
|
+
return { ok: true, project_id: args.project_id, ...thin };
|
|
47298
48867
|
},
|
|
47299
48868
|
// Mirror supabase:validateConfig (worker/index.ts:1826) — reads the project's
|
|
47300
48869
|
// config.toml first via readConfig, then validates its content.
|
|
@@ -47666,14 +49235,7 @@ async function testConnection(projectId) {
|
|
|
47666
49235
|
};
|
|
47667
49236
|
}
|
|
47668
49237
|
function envPrefixForApp(app) {
|
|
47669
|
-
|
|
47670
|
-
case "next":
|
|
47671
|
-
return "NEXT_PUBLIC_";
|
|
47672
|
-
case "vite":
|
|
47673
|
-
return "VITE_";
|
|
47674
|
-
default:
|
|
47675
|
-
return "";
|
|
47676
|
-
}
|
|
49238
|
+
return envPrefixForType(app?.type);
|
|
47677
49239
|
}
|
|
47678
49240
|
function connectionSignature(vars) {
|
|
47679
49241
|
const json2 = JSON.stringify(vars, Object.keys(vars).sort());
|
|
@@ -47696,8 +49258,8 @@ function isSupabaseProject(project) {
|
|
|
47696
49258
|
if (project.supabaseProjectId) return true;
|
|
47697
49259
|
return (project.services || []).some((s) => (s.type || "").startsWith("supabase-"));
|
|
47698
49260
|
}
|
|
47699
|
-
function resolveEnvTargetFile(appFolder, targets) {
|
|
47700
|
-
const preferred = path.join(appFolder,
|
|
49261
|
+
function resolveEnvTargetFile(appFolder, targets, appType) {
|
|
49262
|
+
const preferred = path.join(appFolder, envFileNameForType(appType));
|
|
47701
49263
|
if (targets.some((t) => t.filePath === preferred)) return preferred;
|
|
47702
49264
|
const inFolder = targets.find((t) => t.relativeTo === appFolder);
|
|
47703
49265
|
return inFolder?.filePath ?? preferred;
|
|
@@ -47714,7 +49276,11 @@ async function computeConnectionEnvDrift(projectId) {
|
|
|
47714
49276
|
io2?.emit("connection-drift:checking", { projectId, checking: true });
|
|
47715
49277
|
const preview = await buildConnectionPreview(projectId, opts);
|
|
47716
49278
|
if (!preview.targetPath) return null;
|
|
47717
|
-
const envFile = resolveEnvTargetFile(
|
|
49279
|
+
const envFile = resolveEnvTargetFile(
|
|
49280
|
+
preview.targetPath,
|
|
49281
|
+
await scanEnvTargets(projectId),
|
|
49282
|
+
primaryApp?.type
|
|
49283
|
+
);
|
|
47718
49284
|
const diff = await buildEnvDiff(projectId, envFile, opts);
|
|
47719
49285
|
const signature = connectionSignature(preview.vars);
|
|
47720
49286
|
const urlKey = `${prefix}SUPABASE_URL`;
|
|
@@ -49058,7 +50624,7 @@ async function detectTargets(projectPath) {
|
|
|
49058
50624
|
jetbrains: await isDir(path.join(projectPath, ".idea"))
|
|
49059
50625
|
};
|
|
49060
50626
|
}
|
|
49061
|
-
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 the proxy starts — **no mapping required**, so you can trust it before you add anything. With the proxy running, 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. (On macOS 15+ that system-wide step is no longer permitted to a background helper, so Install falls back to per-user trust — see below.) 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\n**On macOS 15 (Sequoia) and later, Install trusts the CA for your user account.** Apple now routes system-wide trust changes through an authorization dialog that macOS refuses to show to a background helper — being root is no longer enough, and the attempt comes back as `SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible`. Supbuddy still tries the system-wide install first (it works on Sonoma and earlier, and covers every user on the machine); when macOS refuses it, Supbuddy adds the root to your **login keychain** instead and macOS shows *\"You are making changes to your Certificate Trust Settings\"* — confirm with your login password. Browsers honour user-domain trust exactly the same way, and **Uninstall** removes the root from both keychains. If you dismiss that dialog, Supbuddy pins the exact command on screen so you can run it yourself — **without `sudo`**, which would land back in the domain macOS just refused:\n\n```bash\nsecurity add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db \\\n ~/Library/Application\\ Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt\n```\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`. This cleanup runs against whichever keychain the install targets, and trust detection reads **both** the System and login keychains, so a root trusted per-user still reports as installed.\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. One thing the suffix *can* still cause is the opposite symptom — the name not resolving **at all** on Sonoma and later, because mDNS owns the namespace by a path `/etc/resolver` does not govern. That is rarer, it is not slowness, and the only fix is a different TLD; see [the PROXY ERROR banner](#project-shows-a-red-proxy-error-banner-domain-resolves-but-wont-load).\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\nEnforcement has a third state: **unknown**. The 443 probe only means something when something is listening behind the redirect, so if the HTTPS port has no listener — most often when no mapping is enabled yet, which generates a Caddyfile with no site blocks — Supbuddy reports enforcement as unknown rather than off. In that state it shows no \"not enforcing\" badge, no red banner, and never asks for your password: a redirect it cannot observe is not a redirect it can call broken.\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. You have **three minutes** to answer an admin prompt (older builds gave up after 30 seconds and then discarded the result of a password typed later, reporting work that had actually succeeded as failed).\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 mail). 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. the mail catcher's `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. Where a whole section is missing and the rest of your ports sit outside the stock `54320–54329` block, Supbuddy reports that service's port as **unknown** rather than substituting the stock one — on a multi-project machine the stock port is another project's service, and a mapping built from it would open the wrong stack.\n\n#### Supported `config.toml` layout\n\nSupbuddy reads `[api]`, `[db]` (`port` + `shadow_port`), `[db.pooler]`, `[studio]`, `[analytics]`, and the mail catcher. **Supabase CLI 2.x renamed the mail section `[inbucket]` to `[local_smtp]`**; Supbuddy reads whichever one your file declares (`[local_smtp]` wins if both are somehow present) and falls back to the stock 54324/54325/54326 only when it declares neither. Thin's port rewrite targets the section you already have — it never adds the other spelling, because the CLI would ignore it while the diff made the port look moved.\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). That loopback covers **app dev servers only** — Supabase is separated by the port block above, not by the IP, so every project still needs its own Supabase port range in `config.toml`. 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### When macOS won't load `/etc/resolver`: the `/etc/hosts` fallback\n\nOn a small number of Macs the `/etc/resolver` mechanism is simply **inert**. The files are present and correct, Supbuddy's DNS server answers every managed name on `127.0.0.1:5353`, and `scutil --dns` still lists only the system's own resolvers — zero of Supbuddy's. Measured on macOS 26 with a responder that answers *every* query: a custom TLD failed, a custom TLD with a `domain` directive failed, and the real IANA TLD `.dev` failed too. `/etc/hosts` worked. **This is not a TLD problem and renaming your domains will not fix it.**\n\nSupbuddy detects that state and works around it by maintaining a managed block in `/etc/hosts`.\n\n- **How it engages.** After the proxy starts and the resolver files have been written and reloaded, Supbuddy resolves a name that *only* the resolver path can answer. If the files are in sync and that name still doesn't resolve — confirmed, not on a single miss — it writes the block. Nothing branches on your macOS version; it is the observed condition, so it is also correct for a machine locked down by a configuration profile or MDM.\n- **What it costs.** The condition can only be seen *after* the privileged setup has run, so the very first time it is detected you get **one extra password prompt**. Supbuddy then remembers the machine fact, and every later proxy start folds the hosts write into the **same** prompt as the resolver write. Steady state: one prompt, exactly as before. A declined prompt is not retried for 10 minutes, so it can never become a password loop.\n- **What it covers — and what it doesn't.** `/etc/hosts` has no wildcards. The block carries the exact names Supbuddy knows about: every enabled mapping, plus every enabled project's base domain, each pointing at `127.0.0.1` and `::1` (the same answers the DNS server gives). A brand-new subdomain that has no mapping will **not** resolve until you add one — the one behaviour difference you will notice. Everything else, including HTTPS and per-project TLDs, is unchanged.\n- **How to see it.** The block is delimited by `# Supbuddy DNS fallback - Start` / `# Supbuddy DNS fallback - End` — run `grep -A20 'Supbuddy DNS fallback' /etc/hosts`. `get_health` reports it as `dns.resolver.hosts_fallback: true`, and the \"domain doesn't resolve\" banner says so in words. Your original file is copied once to `/etc/hosts.supbuddy-backup` before the first edit.\n- **How to get out of it.** It retires itself: as soon as a proxy start finds the resolver path answering again, Supbuddy removes the block and forgets the machine fact. Stopping the proxy also removes it (the same teardown that removes the resolver files), and `supbuddy reset --deep` removes it for good. You can also delete the block by hand — Supbuddy rewrites it on the next start only if the fault is still there.\n\nNote that this block is **not** the old `# Supbuddy - Start` block from the pre-DNS-server era. That one is legacy, is deleted on every launch, and has nothing to do with this.\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`, `preview_cloud_env`, `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\n `preview_cloud_env` answers what a project's env would MEAN in a cloud box, before anything is pushed. Values are classified, never uniformly substituted — a blanket rewrite silently repoints a project at a different backend, and a blanket copy points a cloud box at a database on somebody's laptop. Each variable comes back as **local** (`127.0.0.1`, a `.local` host, a LAN address — meaningless inside a box), **remote** (correct as-is in both places), **secret**, or **plain**, each with a reason in plain words, plus `needs_attention` — the count of local ones, the only number that implies an action. It is read-only and changes nothing. **Secret values are never returned** — not masked, not truncated, omitted: a masked secret is still a decision to send it somewhere, and a preview has no use for the value.\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. `enforced` is `null` whenever the probe would be meaningless — the proxy is stopped, or nothing is listening on the HTTPS port — and `null` means *unknown*, never a fault: it raises no finding, no degraded flag and no password prompt. `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>`.\n\nA project lands on `host` in exactly three cases:\n\n1. You passed `isolation: 'host'`, or **Settings → Default isolation** is host. (`isolation: 'thin'` forces thin and skips the detection below.)\n2. The project's Supabase stack is **already running on the host outside Supbuddy** — switching would rewrite its `config.toml` ports and orphan that stack, so registration keeps it on host.\n3. Thin was **attempted and failed** — most often because creating the project's `127.0.0.N` loopback alias needs sudo and the prompt was dismissed. The project is left on host with `isolationError` set.\n\nCase 3 is a **fallback, not a deliberate outcome**: retry it with `switch_isolation { target_mode: 'thin' }` and then `apply` the plan that stages (see *Plan / apply for destructive tools*).\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. It is a destructive tool, so unless the client has auto-apply it **stages a plan** rather than switching — call `apply` with the `plan_id` to execute it (see *Plan / apply for destructive tools*). Execution then runs in the background and returns `{ started: true }`; poll `get_project` (`isolation`, and `loopbackIp` for thin) for the current 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.\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`, `switch_isolation`, `write_env_file`, etc.) return a *plan* with a preview instead of a result. The MCP client (or you, in the Activity panel) explicitly calls `apply` with the `plan_id` to execute; `cancel_plan` discards it. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days. A client with **auto-apply** skips staging and executes directly — except `system_wipe`, which always stages.\n\nA staged plan carries two fields an agent should act on:\n\n- **`can_apply: true`** — the plan is applyable. It is on every pending plan, because a plan only exists once scope, argument validation and rate limiting have all passed. In particular it outranks `required_feature`, which is **declarative metadata that nothing enforces** — never read that field as a denial.\n- **`__apply_via`** — the literal next call: `{ tool: 'apply', args: { plan_id } }`. The mirror of `__reversible_via` on completed operations.\n\nThe MCP server states the same contract in its `initialize` instructions, so any client sees it at connect time.\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- **Invite-only, for now** — Supbuddy Cloud is not open signup. You need an invite from the Supbuddy\n operator; redeeming it creates **your own organisation**, with you as its owner. Until you redeem\n one, cloud actions answer *\"Supbuddy Cloud is invite-only. Redeem your invite code to create your\n organisation.\"* Org members cannot issue invites — only the operator can.\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 dialog previews what the project's **env** would mean in the box before you commit: values are classified, never rewritten, and it leads with how many point at *this machine* — those are meaningless inside a box and are the only ones needing a decision. It never blocks the push (a local-looking value may be exactly what you meant) and changes nothing for you. Secrets are listed by name only; their values are never read out of the file. 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### Public addresses, and renaming them\n\nEach app in a cloud box gets a public address of the form\n`<app>.<project>.<org>.supbuddy.cloud` — for example `web.site.acme.supbuddy.cloud`. The `<app>` label\ncomes from the runner serving that port, `<project>` and `<org>` from the slugs you choose. One wildcard\ncertificate is issued per project (`*.<project>.<org>.supbuddy.cloud`) and covers every app under it.\n\n- **Renaming is allowed while boxes are running.** It used to be refused, and for a real reason: a box was\n told its hostnames once, when its machine was created, and that value cannot be changed afterwards — so a\n rename left it serving the old names while the new address showed the editor instead of your app. Boxes\n now *ask* for their hostnames on each heartbeat, so a running box moves itself, usually within a minute.\n- **The old address stops working immediately.** Its DNS records are removed as part of the rename. This is\n deliberate: leaving them would make the old URL resolve and quietly serve the editor, which is more\n confusing than a name that has plainly gone away. Links you have already shared will break.\n- **A rename can succeed while an address is still moving.** Certificates are issued by Let's Encrypt, which\n limits how often the same set of names can be re-issued (5 per week), so renaming back and forth can hit\n that ceiling. The rename itself still applies — you will see *\"The name is changed, but 1 box is still\n moving to it…\"* with the reason, rather than a silent half-rename.\n- **Hostnames need the deployment to be configured for them** (`VERCEL_TOKEN`, `VERCEL_TEAM_ID`,\n `SUPBUDDY_CLOUD_BASE_DOMAIN`). Without those, boxes are still reachable through the editor and the stack\n page says *\"public hostnames are not configured on this deployment\"* rather than showing nothing.\n\n### The Supbuddy panel, inside the box's editor\n\nEvery cloud box's editor carries a **Supbuddy** view in the activity bar — one place to see what the box is\ndoing without leaving it. It reads the status report the box's own supervisor writes, so it adds no\ncredential and no network listener of its own.\n\n- **Box** (the main view) — the box's phase and uptime, then four sections:\n - **Apps** — the runners this project declared. Each shows its state and port, with **Open** (in the\n editor's browser), **Tab** (a real browser tab), **Start** / **Stop**, **Restart** and **Logs**. A\n runner that has *not* started is still listed, because that is usually the one you came to start — and\n a runner that failed shows why (for example *approved for `aaaaaaaa`, HEAD is `bbbbbbbb`* when\n autostart's approved commit no longer matches what the box checked out).\n - **Services** — the stack's own Supabase and sidecar services, with where each one lives. These run on\n separate machines on your private network, so they are *probed* rather than supervised; one whose first\n probe has not landed reads **checking**, not *down*.\n - **Configuration** — read-only: the repository, the commit, the workspace path, the tailnet name and any\n public hostnames. Nothing here is editable, because all of it is decided by the plan that built the box.\n - **System** — the box's own plumbing (clone, sshd, tailnet, credential and module installs). It expands\n itself when something in it is wrong.\n- **Logs** open inline, under the app that produced them, and press again to close. A runner with no output\n says so rather than showing an empty box, and when the supervisor cannot be reached the panel shows *its*\n reason instead of failing quietly.\n- **If the supervisor stops writing, the panel says so** — *\"The supervisor stopped updating 45s ago. What\n is shown below may no longer be true.\"* A stale report is never rendered as healthy.\n- **Apps & Services** — the original compact tree, still available below the panel and collapsed by default.\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- **Supbuddy refuses a cloud-authority sync that would destroy local-only work.** The box clones your\n repository from its **remote**, so it has never seen uncommitted changes or commits you have not\n pushed — and the first pass deletes anything the other side lacks. Rather than let that happen,\n starting sync with the cloud as authority is refused, naming what is at risk and the remedy:\n *\"…has 2 uncommitted changes (commit or stash them), and 1 unpushed commit (push them first).\"* A\n directory that is not a git repository is refused too, since nothing there could be recovered.\n Choosing **this machine's copy** is never blocked — that direction overwrites the box.\n- **Sync survives restarting Supbuddy.** The file synchroniser runs in its own process, so quitting and\n reopening the app (or an update) does not interrupt a running sync; Supbuddy re-adopts the session on\n start and the status badge picks up where it left off.\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 (the padlock stays broken). Detection is by fingerprint and reads **both** the System and login keychains, so neither a stale same-name root from an earlier CA nor a per-user install is misread | Installs it (`security add-trusted-cert`; asks for your password — on macOS 15+ this becomes the per-user install with its own confirmation dialog). Where trust **cannot be read at all** (Windows, or an unreadable keychain) 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. It first checks that the HTTPS port has a listener: with nothing serving behind the redirect a closed 443 says nothing about pf, so that machine gets no finding rather than a false one | **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, **both** `/etc/hosts` blocks (the legacy `# Supbuddy - Start` one and the `# Supbuddy DNS fallback` one), 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 (including the `/etc/hosts` fallback block, which is removed by the same command) 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), falling back to your **login keychain** on macOS 15+ where system-wide trust needs a dialog macOS won't show a background helper; **Uninstall** removes every `Caddy Local Authority` root it added, from **both** keychains. macOS asks for your password each time. If Install can't complete, the exact command to run yourself stays pinned under the row rather than only in a toast.\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 — on macOS 15+ this is the *\"You are making changes to your Certificate Trust Settings\"* dialog for the per-user install. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* → **System** keychain, then the **login** keychain → search for \"Caddy Local Authority\".\n\nIf the install fails with `SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible`, that is macOS 15+ refusing system-wide trust to a background helper; Supbuddy retries per-user automatically, and if you dismiss that dialog it shows you the no-`sudo` command to run yourself.\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### `mail.<project>` opens another project's inbox, or Supabase refuses to start over port 54324\n\nSupabase CLI 2.x renamed the mail-catcher section `[inbucket]` to `[local_smtp]`. Builds up to 3.6.14 only read `[inbucket]`, so a project whose `config.toml` says `[local_smtp] port = 54624` had its mail port silently read as the stock **54324** — which on a multi-project machine is a *different* project's inbox. Three symptoms came from that one cause: the generated `mail.<project>` mapping pointed at 54324, `supabase start` was refused with \"Inbucket needs port 54324 (in use by …)\" for a project that never wanted 54324, and Thin's port rewrite skipped the mail keys entirely (`managed port key \"inbucket.port\" not found in config.toml`). Current builds read whichever section your file declares. If you are on an older build, either update or rename the section to `[inbucket]`; after updating, rescan the project so the mapping is regenerated on the right port.\n\n### Toggling Supabase analytics said it restarted, and the stack never came back\n\nFixed in current builds. `set_supabase_analytics` (and the Supabase tab's analytics toggle) writes the config change and then restarts the stack in the background. Up to 3.6.14 the stop ran first and the start was preflighted only afterwards — so a start that could not succeed left the stack **down**, while the project card and `get_supabase_status` went on reporting every service as running from the snapshot taken before the stop.\n\nTwo things changed. The restart is now preflighted **before** anything is stopped, excluding the ports this project's own containers are about to free: if the start could not succeed, the whole operation is refused with the port and the process holding it, and **the running stack is left running**. And any restart that does fail is recorded on the project — surfaced as `supabase_error` on `get_supabase_status`, with the stack's still-\"running\" services downgraded to `unknown`, because after a failed restart that is what their state actually is. A later successful restart clears it.\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\n**The check waits for the OS to settle, and a single miss no longer raises the banner.** Starting the proxy runs the privileged setup, which kickstarts `mDNSResponder` and rewrites `/etc/resolver` — for a few seconds afterwards macOS legitimately fails to resolve names it is about to serve normally. Up to and including 3.6.14 the check was a single probe fired 1.5 s after that, so it often measured the settling window rather than the machine: the banner cleared on restart and came back \"a few seconds later\", then stayed up until the next start even though every URL worked. Current builds re-probe across roughly the first **13 seconds** and only report a failure that outlives the whole window; a lookup that fails once is also retried before it counts as \"doesn't resolve\". Proxy start is not slowed — the check runs in the background.\n\n**A banner that no longer applies clears itself.** While a reachability fault is showing, Supbuddy re-checks about every **45 seconds** and takes the banner down as soon as the name resolves and the port answers again — so a machine that recovers on its own (a resolver reload finishing, Wi-Fi coming back, another device releasing an mDNS name) no longer needs a proxy restart just to stop showing a stale error. It is observation only: no password prompt, no repair, and no elevation. It also only clears the fault it raised — if a port-forwarding failure has since claimed the banner, that one stays up, and clearing still requires Caddy to be serving and the 443 redirect (when you asked for one) to answer.\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**If the name doesn't resolve at all, the banner now names the cause it actually measured.** Before writing that message Supbuddy probes a name only `/etc/resolver` can answer, and says one of two different things:\n\n- **The resolver path works, and the project is on `.local`.** macOS reserves `.local` for Bonjour/mDNS (RFC 6762), and mDNS is consulted by a path an `/etc/resolver/<suffix>` file does not govern — so the OS can return \"server not found\" for a name whose resolver file is present and correct. Retrying rewrites files that were already right and asks for your password to do it, which is why the message names the durable fix instead: move the project off that TLD, in the project dialog (**Settings → TLD**) or with `supbuddy project set <project> --tld=test`. (This is *not* the `.local` slowness of 3.5.17 and earlier — that was our own DNS server and it is fixed. This is resolution failing outright, which the suffix genuinely can cause.)\n- **The resolver path is dead — the OS is loading no `/etc/resolver` file at all.** Up to 3.6.14 the banner told these users to change their TLD too. That advice is measurably wrong here: on the machine this was diagnosed on, `.local`, `.test`, `.internal` **and** `.dev` all failed while `/etc/hosts` worked, so no suffix recovers it. The banner now says the OS is not loading `/etc/resolver`, that changing TLD will not help, and that Supbuddy has written your domains into `/etc/hosts` as a fallback (or will on the next proxy start). See [the `/etc/hosts` fallback](#when-macos-wont-load-etcresolver-the-etchosts-fallback).\n\n**Automatic repairs stop re-prompting.** When the proxy is running but unreachable, Supbuddy re-runs the privileged setup to recover it — and that batch always asks for your password. In current builds an *automatic* attempt (the app re-attaching, a boot auto-start, the owner-ready repair) is skipped if the **same** failure was already re-applied within the last **10 minutes** and didn't recover; the daemon logs one line and leaves the banner and its diagnosis standing. A repeated prompt that fixes nothing only teaches you to dismiss prompts. Anything **you** initiate — **Retry** in the app, `supbuddy proxy restart`, the MCP `start_proxy` — is never throttled, and a recovery, or a different fault, clears the cooldown immediately.\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\n**If you see \"not enforcing\" or repeated password prompts with no mappings enabled**, you are on a build older than 3.6.12. The 443 probe was gated on the Caddy *process* being alive rather than on something actually listening, so a project with no enabled mappings — whose Caddyfile has no site blocks, leaving nothing bound to 8443 — made a correctly-loaded pf rule look dead: NOT ENFORCING, the red banner, and a repair prompt on every network change. Current builds report that machine as **unknown** and stay quiet. To confirm your ruleset is fine: `sudo pfctl -a 'virtual.localhost' -s nat` lists both `rdr` rules.\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";
|
|
50627
|
+
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 the proxy starts — **no mapping required**, so you can trust it before you add anything. With the proxy running, 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. (On macOS 15+ that system-wide step is no longer permitted to a background helper, so Install falls back to per-user trust — see below.) 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\n**On macOS 15 (Sequoia) and later, Install trusts the CA for your user account.** Apple now routes system-wide trust changes through an authorization dialog that macOS refuses to show to a background helper — being root is no longer enough, and the attempt comes back as `SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible`. Supbuddy still tries the system-wide install first (it works on Sonoma and earlier, and covers every user on the machine); when macOS refuses it, Supbuddy adds the root to your **login keychain** instead and macOS shows *\"You are making changes to your Certificate Trust Settings\"* — confirm with your login password. Browsers honour user-domain trust exactly the same way, and **Uninstall** removes the root from both keychains. If you dismiss that dialog, Supbuddy pins the exact command on screen so you can run it yourself — **without `sudo`**, which would land back in the domain macOS just refused:\n\n```bash\nsecurity add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db \\\n ~/Library/Application\\ Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt\n```\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`. This cleanup runs against whichever keychain the install targets, and trust detection reads **both** the System and login keychains, so a root trusted per-user still reports as installed.\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. One thing the suffix *can* still cause is the opposite symptom — the name not resolving **at all** on Sonoma and later, because mDNS owns the namespace by a path `/etc/resolver` does not govern. That is rarer, it is not slowness, and the only fix is a different TLD; see [the PROXY ERROR banner](#project-shows-a-red-proxy-error-banner-domain-resolves-but-wont-load).\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\nEnforcement has a third state: **unknown**. The 443 probe only means something when something is listening behind the redirect, so if the HTTPS port has no listener — most often when no mapping is enabled yet, which generates a Caddyfile with no site blocks — Supbuddy reports enforcement as unknown rather than off. In that state it shows no \"not enforcing\" badge, no red banner, and never asks for your password: a redirect it cannot observe is not a redirect it can call broken.\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. You have **three minutes** to answer an admin prompt (older builds gave up after 30 seconds and then discarded the result of a password typed later, reporting work that had actually succeeded as failed).\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### Readiness banner\n\nBetween the header and the tabs, a card shows one bordered row per **readiness** finding — dependencies not installed or out of date, Supabase connection vars missing or stale, keys the project's `.env.example` declares that nothing sets. Each row carries the finding's detail, its evidence (project-relative paths and key names, never values), and, where Supbuddy can fix it, a button whose words come from the finding itself: **Run pnpm install** in a pnpm repo, **Run yarn install** in a yarn one. While an install runs, the banner shows a live tail of its output and a **Cancel** button, and the fix buttons are disabled — including when that install was started from the CLI or by an agent. **If the install exits non-zero, the banner says so and keeps the output**: a red row naming the exit code, the last lines of the run still under it, and a **Dismiss** button — it stays until you dismiss it or start another install. A cancelled install is not reported as a failure. A ready project shows no banner. See [Project readiness](#project-readiness-can-it-run).\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings — including readiness findings. 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**The connection panel: which button actually connects your app.** The panel generates the project's Supabase/app-URL variables using the app's framework prefix and targets the file that framework loads (see [Framework env vars: prefix and file](#framework-env-vars-prefix-and-file)). The **Prefix** dropdown opens on the prefix that table gives the app being written to — `NUXT_PUBLIC_` for a Nuxt app, `PUBLIC_` for Astro and SvelteKit, none for Remix or an app whose framework was not detected — and it follows the **Write to** app when you change it. Override it if you keep a different spelling; the dropdown offers every prefix Supbuddy knows, `EXPO_PUBLIC_` included. Two buttons write, and they are not interchangeable:\n\n- **Set env vars** (then **Apply & next**, one file at a time, with a per-key diff) merges the values into the env file your app actually reads — `.env.local` for Next/Vite/SvelteKit, `.env` for Astro/Nuxt/Remix — backing the original up first and preserving your comments. This is the one that connects the app and clears the `supabase env:` warning.\n- **Write .env.supbuddy** writes a **reference** file, `<app>/.env.supbuddy`. No framework auto-loads it, so on its own it changes nothing your app can see and the drift warning stays. It is for copying values out of, or for `source`-ing by hand.\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**Stop kills the whole tree, not just the shell.** A script runs as `<manager> run <name>` under a shell, so the dev server you care about is that shell's child — Supbuddy now signals the **process group** (SIGTERM, then SIGKILL five seconds later), so `next dev`/`vite` and anything they started go down with it. Earlier builds signalled only the tracked shell: the row went back to *stopped* while the server kept running and kept holding its port, and the next Start failed with `EADDRINUSE` against your own leftover process, which you then had to find with `lsof -i :3000` and kill by hand. The same applies to Stop from `supbuddy` and over MCP — all three drive one supervisor.\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 mail). 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. A Thin project keeps that block for as long as it is Thin: anything that **creates or replaces** its `supabase/config.toml` afterwards — `supabase init` from the app or `init_supabase` over MCP on a project that had no Supabase yet, or repointing it with `set_supabase_config_path` — gets the block re-written into the new file, because the freshly generated file declares the stock `54321–54324` ports that belong to whichever project is on Host.\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. the mail catcher's `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. Where a whole section is missing and the rest of your ports sit outside the stock `54320–54329` block, Supbuddy reports that service's port as **unknown** rather than substituting the stock one — on a multi-project machine the stock port is another project's service, and a mapping built from it would open the wrong stack.\n\n#### Supported `config.toml` layout\n\nSupbuddy reads `[api]`, `[db]` (`port` + `shadow_port`), `[db.pooler]`, `[studio]`, `[analytics]`, and the mail catcher. **Supabase CLI 2.x renamed the mail section `[inbucket]` to `[local_smtp]`**; Supbuddy reads whichever one your file declares (`[local_smtp]` wins if both are somehow present) and falls back to the stock 54324/54325/54326 only when it declares neither. Thin's port rewrite targets the section you already have — it never adds the other spelling, because the CLI would ignore it while the diff made the port look moved.\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). That loopback covers **app dev servers only** — Supabase is separated by the port block above, not by the IP, so every project still needs its own Supabase port range in `config.toml`. 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### When macOS won't load `/etc/resolver`: the `/etc/hosts` fallback\n\nOn a small number of Macs the `/etc/resolver` mechanism is simply **inert**. The files are present and correct, Supbuddy's DNS server answers every managed name on `127.0.0.1:5353`, and `scutil --dns` still lists only the system's own resolvers — zero of Supbuddy's. Measured on macOS 26 with a responder that answers *every* query: a custom TLD failed, a custom TLD with a `domain` directive failed, and the real IANA TLD `.dev` failed too. `/etc/hosts` worked. **This is not a TLD problem and renaming your domains will not fix it.**\n\nSupbuddy detects that state and works around it by maintaining a managed block in `/etc/hosts`.\n\n- **How it engages.** After the proxy starts and the resolver files have been written and reloaded, Supbuddy resolves a name that *only* the resolver path can answer. If the files are in sync and that name still doesn't resolve — confirmed, not on a single miss — it writes the block. Nothing branches on your macOS version; it is the observed condition, so it is also correct for a machine locked down by a configuration profile or MDM.\n- **What it costs.** The condition can only be seen *after* the privileged setup has run, so the very first time it is detected you get **one extra password prompt**. Supbuddy then remembers the machine fact, and every later proxy start folds the hosts write into the **same** prompt as the resolver write. Steady state: one prompt, exactly as before. A declined prompt is not retried for 10 minutes, so it can never become a password loop.\n- **What it covers — and what it doesn't.** `/etc/hosts` has no wildcards. The block carries the exact names Supbuddy knows about: every enabled mapping, plus every enabled project's base domain, each pointing at `127.0.0.1` and `::1` (the same answers the DNS server gives). A brand-new subdomain that has no mapping will **not** resolve until you add one — the one behaviour difference you will notice. Everything else, including HTTPS and per-project TLDs, is unchanged.\n- **How to see it.** The block is delimited by `# Supbuddy DNS fallback - Start` / `# Supbuddy DNS fallback - End` — run `grep -A20 'Supbuddy DNS fallback' /etc/hosts`. `get_health` reports it as `dns.resolver.hosts_fallback: true`, and the \"domain doesn't resolve\" banner says so in words. Your original file is copied once to `/etc/hosts.supbuddy-backup` before the first edit.\n- **How to get out of it.** It retires itself: as soon as a proxy start finds the resolver path answering again, Supbuddy removes the block and forgets the machine fact. Stopping the proxy also removes it (the same teardown that removes the resolver files), and `supbuddy reset --deep` removes it for good. You can also delete the block by hand — Supbuddy rewrites it on the next start only if the fault is still there.\n\nNote that this block is **not** the old `# Supbuddy - Start` block from the pre-DNS-server era. That one is legacy, is deleted on every launch, and has nothing to do with this.\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\nSupbuddy picks an app's framework from **evidence, not table position**. It collects every known framework dependency in the package's `package.json` and takes the one whose `dev`/`start` script actually runs it — a package that merely depends on `next` for types is not a Next app, and a package whose scripts run none of them is not listed as an app at all. When two candidates both match, a fixed order breaks the tie, and that order puts **meta-frameworks before the bundlers they sit on**: SvelteKit and Remix-on-Vite both list `vite` as a direct devDependency, so a `\"dev\": \"vite dev\"` script resolves to SvelteKit rather than handing the app `VITE_` variables it never reads.\n\nPort detection then takes the first of: an explicit `-p`/`--port` in the dev/start script, a `PORT=` prefix in that script, a per-framework config file (`vite.config.*` / `svelte.config.*` for Vite and SvelteKit, `astro.config.*`, `nuxt.config.*`), then — for a meta-framework whose dev server *is* Vite — `vite.config.*`, and finally the framework default below.\n\n| Framework dependency | Detected as | Default port |\n| --- | --- | --- |\n| `next` | `next` | 3000 |\n| `vite` | `vite` | 5173 |\n| `@sveltejs/kit` | `sveltekit` | 5173 |\n| `astro` | `astro` | 4321 |\n| `nuxt`, `nuxt3` | `nuxt` | 3000 |\n| `@remix-run/dev`, `@remix-run/serve` | `remix` | **5173** when `vite` is a direct dependency (a `remix vite:dev` app binds Vite's port), otherwise 3000 |\n| `@angular/core` | `node` | 4200 |\n| `@nestjs/core` | `node` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | `node` | none (must be explicit in dev script) |\n\nSvelteKit, Astro, Nuxt and Remix are detected **as themselves**. Earlier versions reported SvelteKit and Remix as `vite` and let Astro and Nuxt fall through to `node`; the label is what decides the env-var prefix and the env file in the next section, so those apps were being handed variables they could not read. SvelteKit and Remix-on-Vite still get Vite's port and Vite's `server.allowedHosts` audit, because those follow the dev server rather than the label.\n\n### Framework env vars: prefix and file\n\nAn app only sees an environment variable if it is spelled with that framework's public prefix **and** written into the file that framework loads. Supbuddy keeps one table for both, and it drives the Connect panel, the connection-drift warning, and `apply_env`:\n\n| Detected as | Public prefix | Env file |\n| --- | --- | --- |\n| `next` | `NEXT_PUBLIC_` | `.env.local` |\n| `vite` | `VITE_` | `.env.local` |\n| `sveltekit` | `PUBLIC_` | `.env.local` |\n| `astro` | `PUBLIC_` | `.env` |\n| `nuxt` | `NUXT_PUBLIC_` | `.env` |\n| `remix` | none | `.env` |\n| `react`, `node`, `unknown`, or undetected | none | `.env.local` |\n\nRemix having no prefix is the right answer rather than a gap: Remix hands values to the client through its loaders (the `window.ENV` pattern), not through a build-time prefix.\n\nThis table is also what the Connect panel's **Prefix** dropdown starts from, so the panel and the drift warning cannot disagree about your app: picking a prefix by hand is an override, not the default.\n\nThe **Env file** column is what Supbuddy creates when the app has no env file yet. When one already exists it is preferred over creating another: the framework's own file wins if present, otherwise any env target already in that folder — and an existing `.env.local` counts as a target for every framework, so an Astro or Nuxt app that already keeps one is not moved to `.env`. Files that look like backups (`.bak`, dated snapshots) or non-local overrides (`.env.production`, `.env.local.live`) are never write targets.\n\nTwo values are deliberately **never** prefixed, whatever the framework: `DATABASE_URL` and the Supabase service-role key. Both are server-only and must not reach a client bundle.\n\nSupbuddy also mirrors what your file already spells. If `SUPABASE_URL`, `SUPABASE_ANON_KEY` or `SUPABASE_STUDIO_URL` is present both bare and prefixed (including `EXPO_PUBLIC_`, which the scanner never emits on its own), both spellings are updated, so neither goes stale.\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## Project readiness (can it run?)\n\nRegistering a project gets it a domain, a certificate and a scripts list. None of that makes it *run*. Two things stop it, and **readiness** is the one check that covers both:\n\n- **Dependencies aren't installed**, or the lockfile has moved on since the last install. Before this check the way you found out was clicking Start on a dev script and reading its `exit 127` several steps downstream of the cause.\n- **Environment variables are missing or stale** — Supbuddy's own Supabase/app-URL values aren't in the file that app's framework actually loads, or keys the project's own `.env.example` declares aren't set anywhere.\n\nEvery finding carries a severity (`error` / `warning` / `info`), a category (`deps` / `env` / `toolchain`), evidence, and — when Supbuddy can genuinely fix it — a **fix descriptor**: the name of a real tool plus the arguments to call it with. The project card's button, `supbuddy ready --fix` and an agent's MCP call all render that same descriptor, so the three cannot drift and all three run the right package manager *for this project*. A finding with **no** fix is advisory on purpose: either Supbuddy has no value to supply (a third-party secret — it will name `STRIPE_SECRET_KEY`, it will never invent one), or it refuses to guess (nothing identifies a package manager, or that manager isn't installed, in which case an install would only exit 127).\n\n**Evidence carries names and verdicts, never values.** A readiness report reaches the MCP audit log, CLI stdout and the UI at once, so a finding names the key and the file — project-relative — and stops there. No secret, no home directory, no `KEY=value`.\n\nSupbuddy **never installs anything on its own**. Readiness reports; a person or an agent decides.\n\n### Variables your code reads that nothing sets\n\nReadiness also **reads your source** — `process.env.KEY`, `process.env['KEY']`, `import.meta.env.KEY`, `Deno.env.get('KEY')` — and names the keys that no env file sets and no example file declares. It looks project-wide, so a monorepo that keeps one `.env` at the root and loads it with `dotenv-cli` isn't reported wholesale.\n\nThis is the one check that **infers** rather than compares, so it is fenced in on every side:\n\n- Severity is **always `info`**. It can never make a project \"not ready\", and it is kept out of the card's issues counter — it appears in the readiness banner, toned down, and nowhere that counts problems.\n- There is **no fix**. Supbuddy has no value for `RESEND_API_KEY` and will not invent one.\n- Every key carries the **first `file:line` that reads it**, so a false positive is dismissible on sight instead of being a claim about your repo you'd have to go audit. The matched line itself is never quoted: one line of source can carry a credential (`process.env.DATABASE_URL ?? 'postgres://user:pw@host/db'`).\n- The finding says in its own words that it is inferred and can be wrong. A key behind a flag you never enable, one your CI supplies, one inside a code sample — from the outside all three look exactly like a variable you forgot.\n\nWhere it doesn't look: anything your `.gitignore` excludes, plus `node_modules`, `dist`, `build`, `.next`, `.nuxt`, `.svelte-kit`, `coverage` and `.git` — and your **tests and build configs** (`*.test.*`, `*.spec.*`, `__tests__/`, `test/`, `tests/`, `*.config.*`), because a variable only a test or a bundler reads isn't something you have to set to run the app. **Comments are not code**: a key that only appears in a comment is not reported, so prose explaining `process.env.SOMETHING` doesn't become a finding about a variable that doesn't exist.\n\nWhat it never reports: `NODE_ENV`, `PORT`, `CI`, `HOME`, `PATH`, `TZ`, `npm_*`, `VERCEL_*`, `XDG_*`, `NEXT_RUNTIME`, Vite's own `MODE` / `DEV` / `PROD` / `SSR` / `BASE_URL`, and the names your OS or terminal supplies — `APPDATA`, `LOCALAPPDATA`, `FORCE_COLOR`, `NO_COLOR`, `COLORTERM`, `TERM`, `SHELL`, `EDITOR`, `SUDO_ASKPASS`. The platform's variables, not yours. (`GH_TOKEN` is *not* on that list: a project that reads it may genuinely need you to provide it.)\n\n**A scan that hits its limits reports nothing at all.** The walk is capped at 2000 files for the whole project and 1 MiB per file; exceeding either yields **no** referenced-variable findings rather than the subset it managed to read. A truncated list under that heading reads exactly like a complete one — you'd set the three keys it named and trust the silence about the rest.\n\n### Install roots: one per lockfile, not one per app\n\nThe unit of install is the directory that owns a lockfile. In a pnpm/yarn/npm workspace that is one directory — the repo root — and a single install covers every app and every shared package in it, so a monorepo with five apps has **one** install root (`\"\"`, the project root). An app becomes its own install root only when it owns its own lockfile; then it gets a second root and its own install.\n\nSupbuddy picks the package manager by strict precedence, first match wins:\n\n| # | Signal | Result |\n| --- | --- | --- |\n| 1 | `packageManager` field in that directory's `package.json` | its name (and version) |\n| 2 | `pnpm-lock.yaml` | pnpm |\n| 3 | `bun.lockb` or `bun.lock` | bun |\n| 4 | `yarn.lock` | yarn |\n| 5 | `package-lock.json` or `npm-shrinkwrap.json` | npm |\n| 6 | none of the above | **undetermined** — never a silent `npm` |\n\nCase 6 is a real answer, not a gap: running `npm install` in what is actually a pnpm repo drops a stray `package-lock.json` into your working tree, which then gets committed and mis-trains every detector that reads it afterwards. So a root with no signal gets no install command and no install button — the finding says so in words instead.\n\nWhether an install has *happened* is read from the manager's own receipt, never from the presence of `node_modules`:\n\n| Manager | Receipt |\n| --- | --- |\n| pnpm | `node_modules/.modules.yaml` |\n| npm | `node_modules/.package-lock.json` |\n| yarn (node-modules linker) | `node_modules/.yarn-state.yml` (Yarn 1 falls back to `node_modules/.yarn-integrity`) |\n| yarn (PnP) | `.yarn/install-state.gz` |\n| bun | `node_modules/.bun-tag`, else `node_modules` itself |\n\nA receipt older than the lockfile is **stale** (a warning: the project runs, it just no longer matches the repo). No receipt is **not installed**. A receipt Supbuddy cannot read is **unknown**, which produces *no finding at all* — receipt filenames drift between manager versions, and a reinstall button in front of a user whose project is fine is worse than saying nothing. **Yarn PnP projects have no `node_modules` and are correctly installed**; they are read from `.yarn/install-state.gz`, so they are never reported as broken.\n\n### What `packageManager` on a project now means\n\n`Project.packageManager` — the value `supbuddy project ls` prints and `get_project` / `list_projects` return — comes from that one detector now, which changes two things:\n\n- It can read **`bun`**.\n- It is **absent** for a project where nothing identifies a manager. Older builds always printed `npm` there, because the old detector's fallback was a guess rather than an observation. A project with no lockfile and no `packageManager` field therefore now shows no package-manager chip at all instead of a confident wrong one.\n\n**Running a script uses that same answer.** Starting or restarting a script — from the Scripts tab, from `supbuddy`, or over MCP — resolves the manager from the project's recorded value, falling back to what the project root shows. If nothing identifies one, **all three refuse and spawn nothing**, naming the cause ( no `packageManager` field, no lockfile ) and the two ways out: add the field, or install once so a lockfile exists, then rescan the project. Earlier builds quietly ran `npm run <script>` in that case, so one repo could be described as npm by the Scripts tab and as undetermined by `supbuddy ready` at the same time.\n\n**Pushing to the cloud uses it too.** The dev command Supbuddy hands each app in a cloud box comes from that same answer — the app's own manager when it owns a lockfile, otherwise the workspace root's, which is what installs every member of a pnpm or yarn monorepo. An app for which nothing identifies a manager is reported as **not running in the cloud, with the reason**, rather than started under a guessed `npm run dev`: the wrong manager resolves a different dependency tree, and npm would drop a stray `package-lock.json` into a workspace that syncs straight back to your machine. The box's own dependency install reads the same lockfile names in the same order, so a repo locked by `bun.lock` (the only lockfile bun 1.2 and later writes) or by `npm-shrinkwrap.json` is installed like any other instead of being skipped and left to fail on its first app start.\n\n### Installing is a job, not a call\n\nA cold install on a monorepo outlives every timeout on the path between you and the worker, so starting one **returns immediately** with a job (`{ id, state: \"running\", argv, rootPath }`) and the output arrives as a stream. Completion is a state transition you can watch or poll; cancellation is a first-class operation.\n\n- **At most one install per install root.** A second request for a root that is already installing hands back the *same* job rather than starting a duplicate — two package managers writing one `node_modules` is a corrupted store, not a slow install. Different roots of one project are independent directories and run at the same time.\n- A **cancelled** install is reported as `cancelled`, never `success`, even if the child happened to exit 0: it may have been halfway through writing the store. Treat the dependencies as not installed and run it again.\n- Deleting a project **stops** the installs still running for it, so nothing keeps writing into a folder you just told Supbuddy to forget.\n- **And its dev servers.** Deleting a project — from the app, from `supbuddy`, or via `delete_project` over MCP — stops every script it had running, for the same reason plus one more: once the project is gone there is no row left to press Stop on, so a survivor holds its port against whatever you register next with nothing in the UI able to reach it. Deleting with **keep data** still stops them; that option preserves the project's data, not a process writing into it.\n\n### On the project card\n\nAn unready project shows a **readiness banner** above the card's tabs: one bordered row per finding, toned by severity, with the finding's detail and its evidence underneath. Where the finding has a fix, the row carries a button whose words come from the descriptor — a yarn project reads **Run yarn install**, a bun project **Run bun install** — and hovering an install button shows the literal command it will run. An env finding's button runs the same `apply_env` merge described under *MCP tool surface*: it writes into the file the framework loads, backing the original up first.\n\nWhile an install runs, the banner shows a live tail of its output (the same ANSI rendering as the Scripts tab) and a **Cancel** button, and every fix button on that project is disabled — including when the install was started from the CLI or by an agent, since all three surfaces drive the same job. Readiness findings also feed the header's **issues counter** and its popover, with the same `→ open {tab}` jump as every other issue.\n\n**A failed install is stated, not swallowed.** An install that exits non-zero does not throw anywhere — the job simply reaches `failed` — so the banner would otherwise just re-render the same \"dependencies are not installed\" row with the output gone, which reads as a button that did nothing. Instead the banner shows **Install failed (exit code N)** and keeps the tail of that run underneath it, so the lines that explain the failure are still on screen when you need them. It persists until you **Dismiss** it or start another install, and it says only what happened and how it ended — Supbuddy does not guess at a cause. A run killed by a signal or one that never started has no exit code to name and carries the runner's own error text instead. An install you **cancelled** is never shown as a failure.\n\nA ready project shows no banner. The card's status dot already carries the positive case.\n\n### One line in `doctor`\n\n`supbuddy doctor` (and the app's **System health** panel) carries a single aggregate warning — `projects-not-ready` — when registered projects wouldn't run: *\"3 project(s) are not ready to run\"*, with the project **names** as evidence and nothing else. That is deliberate. Per-project detail belongs on the card and in `supbuddy ready`, which can name the install root, the key and the file; a machine with twenty projects must still get a report about the *machine*. The line is **advisory**: doctor stages no install and offers no repair for it — it exists to send you to the surface that can explain it. A project whose folder has been deleted is skipped rather than reported forever on the strength of a leftover record, and a project Supbuddy could not read is listed as `(could not be scanned)` rather than quietly counted as healthy.\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- Project readiness (see *Project readiness* above): `get_project_readiness` ( `{ project }` , scope `read`) reports whether each install root's dependencies are installed and whether the app's env vars are present and current. It spawns nothing and writes nothing, so it is safe to poll. Every actionable finding carries a `fix` — `{ tool, args, label, command }` naming a real tool call — which you pass straight to that tool: `install_dependencies` for a missing or stale install, `apply_env` for connection-env drift. Do **not** compose your own remedy and do not run a package-manager command directly; the descriptor already carries the right manager for that project. `install_dependencies` ( `{ project, root? }` , scope `projects`) is **destructive**, so under the default client it returns a plan naming the exact argv and cwd; applying it **starts a job and returns immediately** with an `InstallJob`, never a finished install. Follow it with `tail_install_log` ( `{ job_id }` , scope `read`), which replays what the job already printed and then streams to completion, or stop it with `cancel_install` ( `{ job_id }` , scope `projects`). A second `install_dependencies` for a root that is already installing returns the **same** job rather than starting a duplicate. It refuses, having spawned nothing, when no lockfile or `packageManager` field identifies a manager and when that manager's binary isn't on Supbuddy's PATH.\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`, `preview_cloud_env`, `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\n `preview_cloud_env` answers what a project's env would MEAN in a cloud box, before anything is pushed. Values are classified, never uniformly substituted — a blanket rewrite silently repoints a project at a different backend, and a blanket copy points a cloud box at a database on somebody's laptop. Each variable comes back as **local** (`127.0.0.1`, a `.local` host, a LAN address — meaningless inside a box), **remote** (correct as-is in both places), **secret**, or **plain**, each with a reason in plain words, plus `needs_attention` — the count of local ones, the only number that implies an action. It is read-only and changes nothing. **Secret values are never returned** — not masked, not truncated, omitted: a masked secret is still a decision to send it somewhere, and a preview has no use for the value.\n- Connection / env-target workflow: `preview_connection`, `get_env_targets`, `diff_env`, `apply_env`, `write_connection`, `test_connection`, `dismiss_connection_drift`. **`apply_env` is the one that connects an app**: it merges into the file the framework loads (`.env.local` for Next/Vite/SvelteKit, `.env` for Astro/Nuxt/Remix — see *Framework env vars*), backs it up first, and clears the connection-drift warning. `write_connection` writes a **reference** `<app>/.env.supbuddy` that no framework auto-loads; it never satisfies the app and never clears that warning. Both compute the same values with the app's framework prefix.\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. `enforced` is `null` whenever the probe would be meaningless — the proxy is stopped, or nothing is listening on the HTTPS port — and `null` means *unknown*, never a fault: it raises no finding, no degraded flag and no password prompt. `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>`.\n\nA project lands on `host` in exactly three cases:\n\n1. You passed `isolation: 'host'`, or **Settings → Default isolation** is host. (`isolation: 'thin'` forces thin and skips the detection below.)\n2. The project's Supabase stack is **already running on the host outside Supbuddy** — switching would rewrite its `config.toml` ports and orphan that stack, so registration keeps it on host.\n3. Thin was **attempted and failed** — most often because creating the project's `127.0.0.N` loopback alias needs sudo and the prompt was dismissed. The project is left on host with `isolationError` set.\n\nCase 3 is a **fallback, not a deliberate outcome**: retry it with `switch_isolation { target_mode: 'thin' }` and then `apply` the plan that stages (see *Plan / apply for destructive tools*).\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. It is a destructive tool, so unless the client has auto-apply it **stages a plan** rather than switching — call `apply` with the `plan_id` to execute it (see *Plan / apply for destructive tools*). Execution then runs in the background and returns `{ started: true }`; poll `get_project` (`isolation`, and `loopbackIp` for thin) for the current 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.\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. For a **Thin** project it also re-writes that project's port block into the newly pointed config (reported under `thin_supabase_block`), since the file it points at declares the stock ports. 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`, `switch_isolation`, `write_env_file`, etc.) return a *plan* with a preview instead of a result. The MCP client (or you, in the Activity panel) explicitly calls `apply` with the `plan_id` to execute; `cancel_plan` discards it. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days. A client with **auto-apply** skips staging and executes directly — except `system_wipe`, which always stages.\n\nA staged plan carries two fields an agent should act on:\n\n- **`can_apply: true`** — the plan is applyable. It is on every pending plan, because a plan only exists once scope, argument validation and rate limiting have all passed. In particular it outranks `required_feature`, which is **declarative metadata that nothing enforces** — never read that field as a denial.\n- **`__apply_via`** — the literal next call: `{ tool: 'apply', args: { plan_id } }`. The mirror of `__reversible_via` on completed operations.\n\nThe MCP server states the same contract in its `initialize` instructions, so any client sees it at connect time.\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. Refuses, having spawned nothing, when no `packageManager` field and no lockfile identify a manager for the project — the same verdict readiness gives that repo.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process — the whole process group, so the dev server under the shell goes with it.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process. Refuses on the same terms as `start_script`.\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- **Invite-only, for now** — Supbuddy Cloud is not open signup. You need an invite from the Supbuddy\n operator; redeeming it creates **your own organisation**, with you as its owner. Until you redeem\n one, cloud actions answer *\"Supbuddy Cloud is invite-only. Redeem your invite code to create your\n organisation.\"* Org members cannot issue invites — only the operator can.\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 dialog previews what the project's **env** would mean in the box before you commit: values are classified, never rewritten, and it leads with how many point at *this machine* — those are meaningless inside a box and are the only ones needing a decision. It never blocks the push (a local-looking value may be exactly what you meant) and changes nothing for you. Secrets are listed by name only; their values are never read out of the file. 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### Public addresses, and renaming them\n\nEach app in a cloud box gets a public address of the form\n`<app>.<project>.<org>.supbuddy.cloud` — for example `web.site.acme.supbuddy.cloud`. The `<app>` label\ncomes from the runner serving that port, `<project>` and `<org>` from the slugs you choose. One wildcard\ncertificate is issued per project (`*.<project>.<org>.supbuddy.cloud`) and covers every app under it.\n\n- **Renaming is allowed while boxes are running.** It used to be refused, and for a real reason: a box was\n told its hostnames once, when its machine was created, and that value cannot be changed afterwards — so a\n rename left it serving the old names while the new address showed the editor instead of your app. Boxes\n now *ask* for their hostnames on each heartbeat, so a running box moves itself, usually within a minute.\n- **The old address stops working immediately.** Its DNS records are removed as part of the rename. This is\n deliberate: leaving them would make the old URL resolve and quietly serve the editor, which is more\n confusing than a name that has plainly gone away. Links you have already shared will break.\n- **A rename can succeed while an address is still moving.** Certificates are issued by Let's Encrypt, which\n limits how often the same set of names can be re-issued (5 per week), so renaming back and forth can hit\n that ceiling. The rename itself still applies — you will see *\"The name is changed, but 1 box is still\n moving to it…\"* with the reason, rather than a silent half-rename.\n- **Hostnames need the deployment to be configured for them** (`VERCEL_TOKEN`, `VERCEL_TEAM_ID`,\n `SUPBUDDY_CLOUD_BASE_DOMAIN`). Without those, boxes are still reachable through the editor and the stack\n page says *\"public hostnames are not configured on this deployment\"* rather than showing nothing.\n\n### The Supbuddy panel, inside the box's editor\n\nEvery cloud box's editor carries a **Supbuddy** view in the activity bar — one place to see what the box is\ndoing without leaving it. It reads the status report the box's own supervisor writes, so it adds no\ncredential and no network listener of its own.\n\n- **Box** (the main view) — the box's phase and uptime, then four sections:\n - **Apps** — the runners this project declared. Each shows its state and port, with **Open** (in the\n editor's browser), **Tab** (a real browser tab), **Start** / **Stop**, **Restart** and **Logs**. A\n runner that has *not* started is still listed, because that is usually the one you came to start — and\n a runner that failed shows why (for example *approved for `aaaaaaaa`, HEAD is `bbbbbbbb`* when\n autostart's approved commit no longer matches what the box checked out).\n - **Services** — the stack's own Supabase and sidecar services, with where each one lives. These run on\n separate machines on your private network, so they are *probed* rather than supervised; one whose first\n probe has not landed reads **checking**, not *down*.\n - **Configuration** — read-only: the repository, the commit, the workspace path, the tailnet name and any\n public hostnames. Nothing here is editable, because all of it is decided by the plan that built the box.\n - **System** — the box's own plumbing (clone, sshd, tailnet, credential and module installs). It expands\n itself when something in it is wrong.\n- **Logs** open inline, under the app that produced them, and press again to close. A runner with no output\n says so rather than showing an empty box, and when the supervisor cannot be reached the panel shows *its*\n reason instead of failing quietly.\n- **If the supervisor stops writing, the panel says so** — *\"The supervisor stopped updating 45s ago. What\n is shown below may no longer be true.\"* A stale report is never rendered as healthy.\n- **Apps & Services** — the original compact tree, still available below the panel and collapsed by default.\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- **Supbuddy refuses a cloud-authority sync that would destroy local-only work.** The box clones your\n repository from its **remote**, so it has never seen uncommitted changes or commits you have not\n pushed — and the first pass deletes anything the other side lacks. Rather than let that happen,\n starting sync with the cloud as authority is refused, naming what is at risk and the remedy:\n *\"…has 2 uncommitted changes (commit or stash them), and 1 unpushed commit (push them first).\"* A\n directory that is not a git repository is refused too, since nothing there could be recovered.\n Choosing **this machine's copy** is never blocked — that direction overwrites the box.\n- **Sync survives restarting Supbuddy.** The file synchroniser runs in its own process, so quitting and\n reopening the app (or an update) does not interrupt a running sync; Supbuddy re-adopts the session on\n start and the status badge picks up where it left off.\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*), `ready [<proj>] [--fix] [--only=<category>]` (can a project run? — see *Project readiness (`supbuddy ready`)*), `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 **22 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 (the padlock stays broken). Detection is by fingerprint and reads **both** the System and login keychains, so neither a stale same-name root from an earlier CA nor a per-user install is misread | Installs it (`security add-trusted-cert`; asks for your password — on macOS 15+ this becomes the per-user install with its own confirmation dialog). Where trust **cannot be read at all** (Windows, or an unreadable keychain) 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. It first checks that the HTTPS port has a listener: with nothing serving behind the redirect a closed 443 says nothing about pf, so that machine gets no finding rather than a false one | **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| `projects-not-ready` | warning | Registered projects that wouldn't actually run — dependencies never installed (or a lockfile newer than the last install), or Supbuddy's Supabase connection vars missing from the env file the app loads. **One aggregate line, never one finding per project**, with **project names only** as evidence. A project whose folder is gone is skipped; one Supbuddy couldn't read is listed as `(could not be scanned)` rather than passed as healthy | **Advisory — no auto-fix.** It points at `supbuddy ready` and the project card, which carry the specific finding, the file it names and the one-click fix. Doctor installs nothing |\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### Project readiness (`supbuddy ready`)\n\n```bash\nsupbuddy ready # every registered project\nsupbuddy ready acme # one project (id, or an unambiguous name)\nsupbuddy ready acme --fix # show a manifest, confirm (y/N), then apply each fix\nsupbuddy ready acme --fix --only=deps # deps | env | toolchain\nsupbuddy ready acme --fix --yes # skip the interactive confirm (scripting / CI)\nsupbuddy ready --json # machine-readable\n```\n\n`supbuddy ready` answers \"can this project actually run?\" — see [Project readiness](#project-readiness-can-it-run) for what it checks. It is presented like `supbuddy doctor`: findings grouped by severity (**ERROR**, **WARNING**, **INFO**), a `[fixable]` marker, evidence indented beneath each finding, and a **SCAN ERRORS** block for projects it could not scan. Without `--fix` it mutates nothing.\n\n`--fix` differs from `doctor --fix` in one way that matters: doctor sends a list of check ids back to one repair tool, while each readiness finding already names **its own** tool and arguments, so `ready --fix` calls that. A yarn project gets `yarn install`, a pnpm project `pnpm install`, an env finding gets `apply_env` — the CLI picks none of it. You see the manifest of what will run before anything does; `n` (and a non-interactive terminal without `--yes`) aborts having changed nothing.\n\nBecause an install is a job rather than a call, `--fix` then **follows the install log to completion** and takes that job's outcome as the fix's outcome, so the command blocks with live output instead of exiting `0` over an install that is still resolving.\n\n**Exit codes** mirror `doctor`:\n\n| Code | Meaning |\n|---|---|\n| `0` | Ready — no error-severity findings |\n| `1` | An error-severity finding, or a fix that failed |\n| `2` | The scan could not complete (see **SCAN ERRORS**), so the result is an unknown |\n\n`--only` narrows the verdict as well as the view: a run filtered to `deps` exits on the deps findings alone, because a command that printed three findings and exited on a fourth it never showed you would be lying.\n\nRelated: `supbuddy project ls` now shows a project's connection-env verdict (`env missing` / `env stale`) on its sub-line. It was previously hidden from that list entirely, so the CLI could not tell you the one fact that explains why an app 500s against a stack that is up. Only the verdict is printed — never the target path or a `KEY=value` — for the same reason readiness evidence never carries values.\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, **both** `/etc/hosts` blocks (the legacy `# Supbuddy - Start` one and the `# Supbuddy DNS fallback` one), 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 (including the `/etc/hosts` fallback block, which is removed by the same command) 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), falling back to your **login keychain** on macOS 15+ where system-wide trust needs a dialog macOS won't show a background helper; **Uninstall** removes every `Caddy Local Authority` root it added, from **both** keychains. macOS asks for your password each time. If Install can't complete, the exact command to run yourself stays pinned under the row rather than only in a toast.\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 — on macOS 15+ this is the *\"You are making changes to your Certificate Trust Settings\"* dialog for the per-user install. After install, fully restart your browser (Cmd+Q, not just close window). Verify: *Keychain Access* → **System** keychain, then the **login** keychain → search for \"Caddy Local Authority\".\n\nIf the install fails with `SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible`, that is macOS 15+ refusing system-wide trust to a background helper; Supbuddy retries per-user automatically, and if you dismiss that dialog it shows you the no-`sudo` command to run yourself.\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### `mail.<project>` opens another project's inbox, or Supabase refuses to start over port 54324\n\nSupabase CLI 2.x renamed the mail-catcher section `[inbucket]` to `[local_smtp]`. Builds up to 3.6.14 only read `[inbucket]`, so a project whose `config.toml` says `[local_smtp] port = 54624` had its mail port silently read as the stock **54324** — which on a multi-project machine is a *different* project's inbox. Three symptoms came from that one cause: the generated `mail.<project>` mapping pointed at 54324, `supabase start` was refused with \"Inbucket needs port 54324 (in use by …)\" for a project that never wanted 54324, and Thin's port rewrite skipped the mail keys entirely (`managed port key \"inbucket.port\" not found in config.toml`). Current builds read whichever section your file declares. If you are on an older build, either update or rename the section to `[inbucket]`; after updating, rescan the project so the mapping is regenerated on the right port.\n\n### Toggling Supabase analytics said it restarted, and the stack never came back\n\nFixed in current builds. `set_supabase_analytics` (and the Supabase tab's analytics toggle) writes the config change and then restarts the stack in the background. Up to 3.6.14 the stop ran first and the start was preflighted only afterwards — so a start that could not succeed left the stack **down**, while the project card and `get_supabase_status` went on reporting every service as running from the snapshot taken before the stop.\n\nTwo things changed. The restart is now preflighted **before** anything is stopped, excluding the ports this project's own containers are about to free: if the start could not succeed, the whole operation is refused with the port and the process holding it, and **the running stack is left running**. And any restart that does fail is recorded on the project — surfaced as `supabase_error` on `get_supabase_status`, with the stack's still-\"running\" services downgraded to `unknown`, because after a failed restart that is what their state actually is. A later successful restart clears it.\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\n**The check waits for the OS to settle, and a single miss no longer raises the banner.** Starting the proxy runs the privileged setup, which kickstarts `mDNSResponder` and rewrites `/etc/resolver` — for a few seconds afterwards macOS legitimately fails to resolve names it is about to serve normally. Up to and including 3.6.14 the check was a single probe fired 1.5 s after that, so it often measured the settling window rather than the machine: the banner cleared on restart and came back \"a few seconds later\", then stayed up until the next start even though every URL worked. Current builds re-probe across roughly the first **13 seconds** and only report a failure that outlives the whole window; a lookup that fails once is also retried before it counts as \"doesn't resolve\". Proxy start is not slowed — the check runs in the background.\n\n**A banner that no longer applies clears itself.** While a reachability fault is showing, Supbuddy re-checks about every **45 seconds** and takes the banner down as soon as the name resolves and the port answers again — so a machine that recovers on its own (a resolver reload finishing, Wi-Fi coming back, another device releasing an mDNS name) no longer needs a proxy restart just to stop showing a stale error. It is observation only: no password prompt, no repair, and no elevation. It also only clears the fault it raised — if a port-forwarding failure has since claimed the banner, that one stays up, and clearing still requires Caddy to be serving and the 443 redirect (when you asked for one) to answer.\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**If the name doesn't resolve at all, the banner now names the cause it actually measured.** Before writing that message Supbuddy probes a name only `/etc/resolver` can answer, and says one of two different things:\n\n- **The resolver path works, and the project is on `.local`.** macOS reserves `.local` for Bonjour/mDNS (RFC 6762), and mDNS is consulted by a path an `/etc/resolver/<suffix>` file does not govern — so the OS can return \"server not found\" for a name whose resolver file is present and correct. Retrying rewrites files that were already right and asks for your password to do it, which is why the message names the durable fix instead: move the project off that TLD, in the project dialog (**Settings → TLD**) or with `supbuddy project set <project> --tld=test`. (This is *not* the `.local` slowness of 3.5.17 and earlier — that was our own DNS server and it is fixed. This is resolution failing outright, which the suffix genuinely can cause.)\n- **The resolver path is dead — the OS is loading no `/etc/resolver` file at all.** Up to 3.6.14 the banner told these users to change their TLD too. That advice is measurably wrong here: on the machine this was diagnosed on, `.local`, `.test`, `.internal` **and** `.dev` all failed while `/etc/hosts` worked, so no suffix recovers it. The banner now says the OS is not loading `/etc/resolver`, that changing TLD will not help, and that Supbuddy has written your domains into `/etc/hosts` as a fallback (or will on the next proxy start). See [the `/etc/hosts` fallback](#when-macos-wont-load-etcresolver-the-etchosts-fallback).\n\n**Automatic repairs stop re-prompting.** When the proxy is running but unreachable, Supbuddy re-runs the privileged setup to recover it — and that batch always asks for your password. In current builds an *automatic* attempt (the app re-attaching, a boot auto-start, the owner-ready repair) is skipped if the **same** failure was already re-applied within the last **10 minutes** and didn't recover; the daemon logs one line and leaves the banner and its diagnosis standing. A repeated prompt that fixes nothing only teaches you to dismiss prompts. Anything **you** initiate — **Retry** in the app, `supbuddy proxy restart`, the MCP `start_proxy` — is never throttled, and a recovery, or a different fault, clears the cooldown immediately.\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\n**If you see \"not enforcing\" or repeated password prompts with no mappings enabled**, you are on a build older than 3.6.12. The 443 probe was gated on the Caddy *process* being alive rather than on something actually listening, so a project with no enabled mappings — whose Caddyfile has no site blocks, leaving nothing bound to 8443 — made a correctly-loaded pf rule look dead: NOT ENFORCING, the red banner, and a repair prompt on every network change. Current builds report that machine as **unknown** and stay quiet. To confirm your ruleset is fine: `sudo pfctl -a 'virtual.localhost' -s nat` lists both `rdr` rules.\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";
|
|
49062
50628
|
const HEADER = (filename) => `<!-- AUTO-GENERATED BY SUPBUDDY · DO NOT EDIT (file: ${filename}) -->
|
|
49063
50629
|
`;
|
|
49064
50630
|
function renderReadme(ctx) {
|
|
@@ -50442,12 +52008,13 @@ async function handleTool(tool, args, ctx) {
|
|
|
50442
52008
|
...frameworkConfigTools,
|
|
50443
52009
|
...projectContextTools,
|
|
50444
52010
|
...userSkillTools,
|
|
50445
|
-
...cloudTools
|
|
52011
|
+
...cloudTools,
|
|
52012
|
+
...readinessTools
|
|
50446
52013
|
};
|
|
50447
52014
|
if (table[tool]) return table[tool](args, ctx);
|
|
50448
52015
|
throw new McpError("method_not_found", `Tool ${tool} not implemented yet`);
|
|
50449
52016
|
}
|
|
50450
|
-
const STREAM_TOOLS = /* @__PURE__ */ new Set(["tail_request_logs", "watch_proxy_state", "watch_audit_log", "tail_service_logs", "tail_script_logs", "watch_supabase"]);
|
|
52017
|
+
const STREAM_TOOLS = /* @__PURE__ */ new Set(["tail_request_logs", "watch_proxy_state", "watch_audit_log", "tail_service_logs", "tail_script_logs", "watch_supabase", "tail_install_log"]);
|
|
50451
52018
|
const isStreamTool = (t) => STREAM_TOOLS.has(t);
|
|
50452
52019
|
function sse(res, event, data) {
|
|
50453
52020
|
res.write(`event: ${event}
|
|
@@ -50463,6 +52030,45 @@ function startStream(tool, args, res) {
|
|
|
50463
52030
|
if (tool === "tail_service_logs") return tailServiceLogs(args, res);
|
|
50464
52031
|
if (tool === "tail_script_logs") return tailScriptLogs(args, res);
|
|
50465
52032
|
if (tool === "watch_supabase") return watchSupabase(args, res);
|
|
52033
|
+
if (tool === "tail_install_log") return tailInstallLog(args, res);
|
|
52034
|
+
}
|
|
52035
|
+
function tailInstallLog(args, res) {
|
|
52036
|
+
const job = getInstallJob(args.job_id);
|
|
52037
|
+
if (!job) {
|
|
52038
|
+
sse(res, "error", {
|
|
52039
|
+
kind: "error",
|
|
52040
|
+
message: `Install job "${args.job_id}" is not known. Job ids come from install_dependencies, or from get_project_readiness's activeInstall.`
|
|
52041
|
+
});
|
|
52042
|
+
res.end();
|
|
52043
|
+
return;
|
|
52044
|
+
}
|
|
52045
|
+
for (const event of getInstallLogEvents(args.job_id)) sse(res, "log", { kind: "log", ...event });
|
|
52046
|
+
if (job.state !== "running") {
|
|
52047
|
+
sse(res, "state", { kind: "state", ...job });
|
|
52048
|
+
res.end();
|
|
52049
|
+
return;
|
|
52050
|
+
}
|
|
52051
|
+
let ended = false;
|
|
52052
|
+
const onLog = (event) => {
|
|
52053
|
+
if (ended || event.jobId !== args.job_id) return;
|
|
52054
|
+
sse(res, "log", { kind: "log", ...event });
|
|
52055
|
+
};
|
|
52056
|
+
const onState = (updated) => {
|
|
52057
|
+
if (ended || updated.id !== args.job_id) return;
|
|
52058
|
+
sse(res, "state", { kind: "state", ...updated });
|
|
52059
|
+
if (updated.state === "running") return;
|
|
52060
|
+
cleanup();
|
|
52061
|
+
res.end();
|
|
52062
|
+
};
|
|
52063
|
+
const cleanup = () => {
|
|
52064
|
+
if (ended) return;
|
|
52065
|
+
ended = true;
|
|
52066
|
+
installEvents.off("log", onLog);
|
|
52067
|
+
installEvents.off("state", onState);
|
|
52068
|
+
};
|
|
52069
|
+
installEvents.on("log", onLog);
|
|
52070
|
+
installEvents.on("state", onState);
|
|
52071
|
+
res.on("close", cleanup);
|
|
50466
52072
|
}
|
|
50467
52073
|
function watchSupabase(args, res) {
|
|
50468
52074
|
const onEvent = ({ event, payload }) => {
|
|
@@ -50628,6 +52234,11 @@ const TOOL_DESCRIPTIONS = {
|
|
|
50628
52234
|
switch_isolation: "Switch a project between host and thin isolation. Under auto_apply:false (the default) this STAGES a plan — call apply({plan_id}) to execute it. Execution then runs in the background — poll get_project until `isolation` (and `loopbackIp` for thin) reflect the target. THIN (recommended, fast: loopback alias + proxy repoint) gives the project its own 127.0.0.N so apps keep canonical ports like :3000. Use HOST only when the project's Supabase runs on the host independently of Supbuddy.",
|
|
50629
52235
|
import_bundle: 'Import a Supbuddy bundle (projects + mappings exported from another machine). The imported project lands on HOST isolation — after import, switch it to THIN (switch_isolation { target_mode: "thin" }) so its apps keep canonical ports (e.g. :3000) on a per-project loopback IP; keep HOST only if its Supabase runs on the host outside Supbuddy.',
|
|
50630
52236
|
update_project: 'Update a project\'s name, domain, per-project TLD override, or enabled state (patch.isolation:"host" exists only as a repair hatch for half-provisioned projects — it writes the field with NO side effects). Set `patch.tld` to a single DNS label to move this project onto its own TLD durably (e.g. patch.tld:"portal" → `cueplusplus.portal`) while other projects stay on the global Default TLD; it repoints the base domain + mappings live and survives restarts. Pass `patch.tld:""` (empty) to CLEAR the override and return the project to the global Default TLD. To change isolation, use switch_isolation (thin recommended: per-project loopback IP, apps keep canonical ports like :3000).',
|
|
52237
|
+
write_connection: "Write a REFERENCE env file — `<app>/.env.supbuddy` — holding the project's live Supabase / app-URL values. It does NOT connect the app. No framework auto-loads `.env.supbuddy`: Next, Vite and SvelteKit read `.env.local`, Astro, Nuxt and Remix read `.env`, and nothing reads `.env.supbuddy` unless the user sources it by hand. So the values never reach the running app, and the project's connection-drift warning (`supabase env: not connected` / `supabase env: out of date`) will NOT clear — drift is measured against the file the framework actually reads, and `.env.supbuddy` can never be that file. TO ACTUALLY CONNECT AN APP, USE `apply_env`: call `get_env_targets` to get the real target `file_path`, optionally `diff_env` to preview, then `apply_env { project_id, file_path }` — it merges into the file the app reads, backs it up first, preserves comments, and clears the warning. Reach for `write_connection` only when the user explicitly asks for a standalone reference/scratch file to copy values out of.",
|
|
52238
|
+
get_project_readiness: "Report what stands between a project and actually RUNNING, before you try to run it: whether each install root's dependencies are installed (and WHICH package manager this project uses — read it from the report, never assume npm), whether Supbuddy's Supabase / app-URL env vars are present and current in the file the app's framework really loads, and which keys the project's own `.env.example` declares that nothing sets. Read-only: it spawns nothing, writes nothing and starts no install, so it is safe to poll. Takes `project` — an id, or a name when that is unambiguous. EVERY ACTIONABLE FINDING CARRIES A `fix`: an object naming a real tool call (`fix.tool` + `fix.args`, plus `fix.label` for a button and `fix.command` for display) that you pass straight to that tool — `install_dependencies` for a missing or stale install, `apply_env` for connection-env drift. Do NOT compose your own remedy and do NOT run a package-manager command yourself; the descriptor already carries the right words for THIS project. A finding with no `fix` is advisory on purpose — Supbuddy has no value to supply (a third-party secret) or refuses to guess (no lockfile identifies a package manager, or that manager is not installed, in which case an install would only exit 127). `evidence` is project-relative paths and key NAMES only, never values, so it is safe to show the user verbatim. `ok` is the absence of any error-severity finding; `ok:false` with no error finding means an install is in flight — see `activeInstall`, and wait rather than starting another.",
|
|
52239
|
+
install_dependencies: 'Install a project\'s dependencies with the package manager THAT PROJECT actually uses — read it from get_project_readiness rather than assuming npm, because running npm in a pnpm or yarn repo writes a stray package-lock.json into the user\'s working tree. Takes `project` (an id, or an unambiguous name) and `root`: an install root\'s project-relative path, where "" is the project root itself. A monorepo has one install root per LOCKFILE, not one per app, so a pnpm workspace with five apps has a single root ("") whose one install covers all of them; an app only gets its own root when it owns its own lockfile. Omit `root` only for a project that has exactly one — with several it refuses rather than guess. The usual way to call this is to pass a readiness finding\'s `fix.args` through verbatim. IT STARTS A JOB AND RETURNS IMMEDIATELY: the result is an InstallJob ({ id, state: "running", argv, rootPath }), NOT a finished install. Follow it with tail_install_log({ job_id }) — which replays what it already printed and then streams to completion — or poll get_project_readiness; stop it with cancel_install({ job_id }). Do not re-call this to "check" on it: a second request for a root that is already installing returns the SAME job rather than starting a duplicate, because two package managers writing one node_modules is a corrupted store. It REFUSES, having spawned nothing, when no packageManager field or lockfile identifies a manager, and when that manager\'s binary is not on Supbuddy\'s PATH (the install would exit 127); in both cases the message says which. Destructive, so under the default client it returns a PLAN naming the exact argv and cwd — show it, then apply({ plan_id }).',
|
|
52240
|
+
cancel_install: 'Stop a running dependency install: SIGTERM, then SIGKILL after a grace period. Takes the `job_id` install_dependencies returned (or the one on get_project_readiness\'s `activeInstall`) and returns the job in its terminal state. The job becomes "cancelled" rather than "failed" even if the child happened to exit 0, because a killed install may have been halfway through writing the store — treat the dependencies as NOT installed and re-run it. Cancelling a job that has already finished is a no-op, not an error.',
|
|
52241
|
+
tail_install_log: "Stream one dependency install's output over SSE. Takes `job_id`. It REPLAYS the lines the job has already printed — install_dependencies returns before the install finishes, so the most useful output is usually already past — then follows live, then closes itself when the job reaches a terminal state, so awaiting the end of this stream is how you wait for an install to finish. Frames are `log` ({ line, type: stdout|stderr, timestamp }) and `state` (the InstallJob); the last `state` frame carries the outcome. A job id the daemon no longer knows (fifty installs later, or across a restart) returns one `error` frame and closes.",
|
|
50631
52242
|
doctor: "Read-only system health & drift scan. Takes no arguments and mutates NOTHING: it returns a Report of findings (each with a `checkId`, severity, evidence, and whether it is `fixable`). To repair a finding, call the SEPARATE `doctor_fix` tool with the check ids — that tool is system-scoped and confirm-gated. Always scan first and show the user the findings before proposing any fix.",
|
|
50632
52243
|
doctor_fix: 'Apply opt-in System Doctor repairs. Takes `check_ids:["<checkId>", …]` (obtained from a prior `doctor` scan) and runs those checks\' fixes. DESTRUCTIVE and confirm-gated exactly like uninstall_ca: it may remove docker containers, delete keychain certificates, or modify system files, and requires the user to approve a confirmation before it runs. Scan with `doctor` first and show the user the manifest.',
|
|
50633
52244
|
system_wipe: 'Tiered SYSTEM RESET — removes Supbuddy\'s footprint from this machine. `tier:"soft"` resets app state (projects, mappings, settings, MCP clients) and clears caches; `tier:"deep"` additionally stops all services and removes the Caddy containers, the /etc/hosts + resolver + pf-redirect + loopback-alias system integrations, the trusted CA, and dead MCP secrets. EXTREMELY DESTRUCTIVE and doubly gated: it ALWAYS returns a plan first (never auto-applies, 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. Every step that destroys unrecoverable data backs it up first and the whole wipe aborts if a backup fails; the backup directory is named in the preview. There is a third tier that also deletes project data, repo artifacts and every credential, then uninstalls the service and removes the app-data directory — it is NOT available here (it would delete your own token mid-call and the daemon cannot uninstall itself). Tell the user to run `supbuddy reset --tier full` in a terminal instead.'
|
|
@@ -52452,9 +54063,9 @@ async function refreshSubdomains(projectId) {
|
|
|
52452
54063
|
}
|
|
52453
54064
|
io.on("connection", (socket2) => {
|
|
52454
54065
|
console.log(`[Worker] Client connected: ${socket2.id}`);
|
|
52455
|
-
const
|
|
52456
|
-
console.log(`[Worker] State snapshot → socket: ${
|
|
52457
|
-
socket2.emit("state-snapshot",
|
|
54066
|
+
const snapshot2 = buildStateSnapshot(useStore.getState());
|
|
54067
|
+
console.log(`[Worker] State snapshot → socket: ${snapshot2.projects.length} projects, ${snapshot2.mappings.length} mappings`);
|
|
54068
|
+
socket2.emit("state-snapshot", snapshot2);
|
|
52458
54069
|
socket2.on("request:state-snapshot", () => {
|
|
52459
54070
|
socket2.emit("state-snapshot", buildStateSnapshot(useStore.getState()));
|
|
52460
54071
|
});
|
|
@@ -53656,14 +55267,30 @@ process.on("message", async (message) => {
|
|
|
53656
55267
|
case "projects:delete": {
|
|
53657
55268
|
const store2 = useStore.getState();
|
|
53658
55269
|
const { id, keepData } = message.data;
|
|
53659
|
-
const
|
|
55270
|
+
const snapshot2 = store2.getProject(id);
|
|
53660
55271
|
store2.deleteProject(id);
|
|
53661
55272
|
process.send({ type: "projects:delete:response", data: { success: true } });
|
|
55273
|
+
void __vitePreload(async () => {
|
|
55274
|
+
const { cancelInstallsForProject: cancelInstallsForProject2 } = await Promise.resolve().then(() => index$3);
|
|
55275
|
+
return { cancelInstallsForProject: cancelInstallsForProject2 };
|
|
55276
|
+
}, false ? __VITE_PRELOAD__ : void 0).then(({ cancelInstallsForProject: cancelInstallsForProject2 }) => cancelInstallsForProject2(id)).then((stopped) => {
|
|
55277
|
+
if (stopped.length > 0) {
|
|
55278
|
+
console.log(`[Worker] Cancelled ${stopped.length} install(s) for deleted project ${id}`);
|
|
55279
|
+
}
|
|
55280
|
+
}).catch((err) => console.error(`[Worker] Install cancellation failed for deleted project ${id}:`, err));
|
|
55281
|
+
void __vitePreload(async () => {
|
|
55282
|
+
const { stopAllProjectScripts: stopAllProjectScripts2 } = await Promise.resolve().then(() => scriptManager);
|
|
55283
|
+
return { stopAllProjectScripts: stopAllProjectScripts2 };
|
|
55284
|
+
}, false ? __VITE_PRELOAD__ : void 0).then(({ stopAllProjectScripts: stopAllProjectScripts2 }) => stopAllProjectScripts2(id, io)).then((stopped) => {
|
|
55285
|
+
if (stopped.length > 0) {
|
|
55286
|
+
console.log(`[Worker] Stopped ${stopped.length} script(s) for deleted project ${id}: ${stopped.join(", ")}`);
|
|
55287
|
+
}
|
|
55288
|
+
}).catch((err) => console.error(`[Worker] Script cleanup failed for deleted project ${id}:`, err));
|
|
53662
55289
|
if (isCaddyRunning()) {
|
|
53663
55290
|
reloadCaddyConfig().then(() => reapplyDnsResolversIfDrifted("projects:delete")).catch((err) => console.error("[Worker] Caddy reload / DNS re-apply after project delete failed:", err));
|
|
53664
55291
|
}
|
|
53665
|
-
if (!keepData &&
|
|
53666
|
-
void cleanupProjectRuntime(
|
|
55292
|
+
if (!keepData && snapshot2) {
|
|
55293
|
+
void cleanupProjectRuntime(snapshot2).catch(
|
|
53667
55294
|
(err) => console.error(`[Worker] Background cleanup failed for deleted project ${id}:`, err)
|
|
53668
55295
|
);
|
|
53669
55296
|
}
|
|
@@ -53897,6 +55524,23 @@ process.on("message", async (message) => {
|
|
|
53897
55524
|
const initStore = useStore.getState();
|
|
53898
55525
|
const project = initStore.projects.find((p) => p.path === projectPath);
|
|
53899
55526
|
if (project) {
|
|
55527
|
+
try {
|
|
55528
|
+
const block = await reapplyThinSupabaseBlock(project);
|
|
55529
|
+
if (block) {
|
|
55530
|
+
console.log(
|
|
55531
|
+
`[Supabase] thin port block applied after init for ${project.id}: ${block.projectId} @ api ${block.ports["api.port"]}`
|
|
55532
|
+
);
|
|
55533
|
+
}
|
|
55534
|
+
} catch (blockErr) {
|
|
55535
|
+
const msg2 = blockErr?.message ?? String(blockErr);
|
|
55536
|
+
console.error(`[Supabase] thin port block allocation failed for ${project.id}:`, msg2);
|
|
55537
|
+
globalThis.io?.emit("supabase:operation", {
|
|
55538
|
+
projectId: project.id,
|
|
55539
|
+
operation: "start",
|
|
55540
|
+
status: "error",
|
|
55541
|
+
message: `Supabase was initialized, but no thin port block could be assigned (${msg2}). Starting it now would collide with the stock 54321-54324 ports.`
|
|
55542
|
+
});
|
|
55543
|
+
}
|
|
53900
55544
|
try {
|
|
53901
55545
|
const scanResult = await scanProject(projectPath, {
|
|
53902
55546
|
supabasePath: project.supabasePath,
|
|
@@ -54242,7 +55886,7 @@ process.on("message", async (message) => {
|
|
|
54242
55886
|
const mapping = store2.addMapping(projectId, domain, port);
|
|
54243
55887
|
process.send({ type: "mappings:create:response", data: mapping });
|
|
54244
55888
|
if (isCaddyRunning() && mapping.enabled) {
|
|
54245
|
-
reloadCaddyConfig().catch((err) => console.error("[Worker]
|
|
55889
|
+
reloadCaddyConfig().then(() => reapplyDnsResolversIfDrifted("mappings:create")).catch((err) => console.error("[Worker] Caddy reload / DNS re-apply after mapping create failed:", err));
|
|
54246
55890
|
}
|
|
54247
55891
|
break;
|
|
54248
55892
|
}
|
|
@@ -54975,6 +56619,19 @@ process.on("message", async (message) => {
|
|
|
54975
56619
|
break;
|
|
54976
56620
|
}
|
|
54977
56621
|
try {
|
|
56622
|
+
const { resolveRunPackageManager: resolveRunPackageManager2 } = await __vitePreload(async () => {
|
|
56623
|
+
const { resolveRunPackageManager: resolveRunPackageManager3 } = await Promise.resolve().then(() => runManager);
|
|
56624
|
+
return { resolveRunPackageManager: resolveRunPackageManager3 };
|
|
56625
|
+
}, false ? __VITE_PRELOAD__ : void 0);
|
|
56626
|
+
const resolved = await resolveRunPackageManager2({
|
|
56627
|
+
projectId,
|
|
56628
|
+
projectPath: project.path,
|
|
56629
|
+
recorded: project.packageManager
|
|
56630
|
+
});
|
|
56631
|
+
if (resolved.manager === null) {
|
|
56632
|
+
process.send({ type: "scripts:start:response", error: resolved.reason });
|
|
56633
|
+
break;
|
|
56634
|
+
}
|
|
54978
56635
|
const { startScript: startScript2 } = await __vitePreload(async () => {
|
|
54979
56636
|
const { startScript: startScript3 } = await Promise.resolve().then(() => scriptManager);
|
|
54980
56637
|
return { startScript: startScript3 };
|
|
@@ -54984,7 +56641,7 @@ process.on("message", async (message) => {
|
|
|
54984
56641
|
project.path,
|
|
54985
56642
|
scriptName,
|
|
54986
56643
|
script.command,
|
|
54987
|
-
|
|
56644
|
+
resolved.manager,
|
|
54988
56645
|
io
|
|
54989
56646
|
);
|
|
54990
56647
|
process.send({ type: "scripts:start:response", data: { success: true } });
|
|
@@ -55069,6 +56726,19 @@ process.on("message", async (message) => {
|
|
|
55069
56726
|
break;
|
|
55070
56727
|
}
|
|
55071
56728
|
try {
|
|
56729
|
+
const { resolveRunPackageManager: resolveRunPackageManager2 } = await __vitePreload(async () => {
|
|
56730
|
+
const { resolveRunPackageManager: resolveRunPackageManager3 } = await Promise.resolve().then(() => runManager);
|
|
56731
|
+
return { resolveRunPackageManager: resolveRunPackageManager3 };
|
|
56732
|
+
}, false ? __VITE_PRELOAD__ : void 0);
|
|
56733
|
+
const resolved = await resolveRunPackageManager2({
|
|
56734
|
+
projectId,
|
|
56735
|
+
projectPath: project.path,
|
|
56736
|
+
recorded: project.packageManager
|
|
56737
|
+
});
|
|
56738
|
+
if (resolved.manager === null) {
|
|
56739
|
+
process.send({ type: "scripts:restart:response", error: resolved.reason });
|
|
56740
|
+
break;
|
|
56741
|
+
}
|
|
55072
56742
|
const { restartScript: restartScript2 } = await __vitePreload(async () => {
|
|
55073
56743
|
const { restartScript: restartScript3 } = await Promise.resolve().then(() => scriptManager);
|
|
55074
56744
|
return { restartScript: restartScript3 };
|
|
@@ -55078,7 +56748,7 @@ process.on("message", async (message) => {
|
|
|
55078
56748
|
project.path,
|
|
55079
56749
|
scriptName,
|
|
55080
56750
|
script.command,
|
|
55081
|
-
|
|
56751
|
+
resolved.manager,
|
|
55082
56752
|
io
|
|
55083
56753
|
);
|
|
55084
56754
|
process.send({ type: "scripts:restart:response", data: { success: true } });
|
|
@@ -55594,6 +57264,49 @@ process.on("message", async (message) => {
|
|
|
55594
57264
|
}
|
|
55595
57265
|
break;
|
|
55596
57266
|
}
|
|
57267
|
+
case "readiness:get": {
|
|
57268
|
+
try {
|
|
57269
|
+
const { readinessTools: readinessTools2 } = await __vitePreload(async () => {
|
|
57270
|
+
const { readinessTools: readinessTools3 } = await Promise.resolve().then(() => readiness);
|
|
57271
|
+
return { readinessTools: readinessTools3 };
|
|
57272
|
+
}, false ? __VITE_PRELOAD__ : void 0);
|
|
57273
|
+
const data = await readinessTools2.get_project_readiness({ project: message.data?.project });
|
|
57274
|
+
process.send({ type: "readiness:get:response", _rid: message._rid, data });
|
|
57275
|
+
} catch (error) {
|
|
57276
|
+
process.send({ type: "readiness:get:response", _rid: message._rid, error: error.message });
|
|
57277
|
+
}
|
|
57278
|
+
break;
|
|
57279
|
+
}
|
|
57280
|
+
case "readiness:apply-fix": {
|
|
57281
|
+
try {
|
|
57282
|
+
const { applyReadinessFix: applyReadinessFix2 } = await __vitePreload(async () => {
|
|
57283
|
+
const { applyReadinessFix: applyReadinessFix3 } = await Promise.resolve().then(() => readinessFix);
|
|
57284
|
+
return { applyReadinessFix: applyReadinessFix3 };
|
|
57285
|
+
}, false ? __VITE_PRELOAD__ : void 0);
|
|
57286
|
+
const fix = message.data?.fix ?? {};
|
|
57287
|
+
const data = await applyReadinessFix2({
|
|
57288
|
+
fix: { tool: fix.tool, args: fix.args },
|
|
57289
|
+
projectId: message.data?.project
|
|
57290
|
+
});
|
|
57291
|
+
process.send({ type: "readiness:apply-fix:response", _rid: message._rid, data });
|
|
57292
|
+
} catch (error) {
|
|
57293
|
+
process.send({ type: "readiness:apply-fix:response", _rid: message._rid, error: error.message });
|
|
57294
|
+
}
|
|
57295
|
+
break;
|
|
57296
|
+
}
|
|
57297
|
+
case "readiness:cancel": {
|
|
57298
|
+
try {
|
|
57299
|
+
const { readinessTools: readinessTools2 } = await __vitePreload(async () => {
|
|
57300
|
+
const { readinessTools: readinessTools3 } = await Promise.resolve().then(() => readiness);
|
|
57301
|
+
return { readinessTools: readinessTools3 };
|
|
57302
|
+
}, false ? __VITE_PRELOAD__ : void 0);
|
|
57303
|
+
const data = await readinessTools2.cancel_install({ job_id: message.data?.job_id });
|
|
57304
|
+
process.send({ type: "readiness:cancel:response", _rid: message._rid, data });
|
|
57305
|
+
} catch (error) {
|
|
57306
|
+
process.send({ type: "readiness:cancel:response", _rid: message._rid, error: error.message });
|
|
57307
|
+
}
|
|
57308
|
+
break;
|
|
57309
|
+
}
|
|
55597
57310
|
case "mcp:auto-install": {
|
|
55598
57311
|
const req = message.data ?? {};
|
|
55599
57312
|
try {
|
|
@@ -60425,6 +62138,61 @@ function corruptFinding(p, reason) {
|
|
|
60425
62138
|
fixable: true
|
|
60426
62139
|
};
|
|
60427
62140
|
}
|
|
62141
|
+
const UNSCANNABLE = "(could not be scanned)";
|
|
62142
|
+
async function detectProjectsNotReady(deps = {}) {
|
|
62143
|
+
const io2 = deps.io ?? nodeReadinessIo();
|
|
62144
|
+
const projects = useStore.getState().projects ?? [];
|
|
62145
|
+
const notReady = [];
|
|
62146
|
+
const unscannable = [];
|
|
62147
|
+
for (const project of projects) {
|
|
62148
|
+
const projectPath = project.path;
|
|
62149
|
+
if (!projectPath) continue;
|
|
62150
|
+
try {
|
|
62151
|
+
if (await io2.statMtimeMs(projectPath) === null) continue;
|
|
62152
|
+
const ready = await projectIsReady(
|
|
62153
|
+
{
|
|
62154
|
+
projectId: project.id,
|
|
62155
|
+
projectPath,
|
|
62156
|
+
// The wire shape, unconverted: `composeReadiness`/`projectIsReady` is the
|
|
62157
|
+
// declared seam for the absolute-vs-relative app path, and a second conversion
|
|
62158
|
+
// here would be a second copy of that rule, free to disagree.
|
|
62159
|
+
apps: (project.apps ?? []).map((app) => ({
|
|
62160
|
+
name: app.name,
|
|
62161
|
+
path: app.path,
|
|
62162
|
+
type: app.type
|
|
62163
|
+
})),
|
|
62164
|
+
// Already classified and persisted; never recomputed here.
|
|
62165
|
+
drift: project.connectionEnvDrift ?? null
|
|
62166
|
+
},
|
|
62167
|
+
io2
|
|
62168
|
+
);
|
|
62169
|
+
if (!ready) notReady.push(project.name);
|
|
62170
|
+
} catch {
|
|
62171
|
+
unscannable.push(project.name);
|
|
62172
|
+
}
|
|
62173
|
+
}
|
|
62174
|
+
const evidence = [...notReady, ...unscannable.map((name2) => `${name2} ${UNSCANNABLE}`)];
|
|
62175
|
+
if (evidence.length === 0) return null;
|
|
62176
|
+
return {
|
|
62177
|
+
checkId: "projects-not-ready",
|
|
62178
|
+
severity: "warning",
|
|
62179
|
+
category: "app-state",
|
|
62180
|
+
title: `${evidence.length} project(s) are not ready to run`,
|
|
62181
|
+
detail: `These projects are registered and proxied, but something would stop them running: dependencies that were never installed (or a lockfile newer than the last install), or Supbuddy's own Supabase connection vars missing from the env file the app actually loads. Run \`supbuddy ready\` (or open the project card) for the specific finding, the file it names and the one-click fix — this line deliberately says no more than that there is something to look at, so a machine with twenty projects still gets a report about the MACHINE. Supbuddy installs nothing on its own: readiness reports, you decide. A project marked ${UNSCANNABLE} is one Supbuddy could not read — it is listed because a scan that could not complete is not a passing scan, not because it is known to be broken.`,
|
|
62182
|
+
evidence,
|
|
62183
|
+
fixable: false
|
|
62184
|
+
};
|
|
62185
|
+
}
|
|
62186
|
+
const projectsNotReady = {
|
|
62187
|
+
id: "projects-not-ready",
|
|
62188
|
+
title: "Registered projects that are not ready to run",
|
|
62189
|
+
severity: "warning",
|
|
62190
|
+
category: "app-state",
|
|
62191
|
+
// Takes nothing from the context: no docker, no shell, no elevation. See the header.
|
|
62192
|
+
detect: () => detectProjectsNotReady(),
|
|
62193
|
+
// Advisory. The fix lives where it can be described as data — see the header.
|
|
62194
|
+
plan: () => []
|
|
62195
|
+
};
|
|
60428
62196
|
const RESOLVER_DIR = "/etc/resolver";
|
|
60429
62197
|
const SAFE_SUFFIX_RE = /^[a-z0-9.-]+$/i;
|
|
60430
62198
|
const isSafeSuffix = (s) => SAFE_SUFFIX_RE.test(s) && s !== "." && s !== "..";
|
|
@@ -60859,6 +62627,10 @@ const ALL_CHECKS = [
|
|
|
60859
62627
|
orphanSupabaseVolumes,
|
|
60860
62628
|
unmanagedSupabase,
|
|
60861
62629
|
stateCorrupt,
|
|
62630
|
+
// Beside state-corrupt, the other app-state check, and after it: "the registry itself did not
|
|
62631
|
+
// load" outranks "a project inside it cannot run" — the second is meaningless while the first
|
|
62632
|
+
// holds, since an empty registry has no projects to judge.
|
|
62633
|
+
projectsNotReady,
|
|
60862
62634
|
staleResolverFiles,
|
|
60863
62635
|
pfConfBackups,
|
|
60864
62636
|
orphanLaunchagents,
|
|
@@ -61052,3 +62824,55 @@ const cliUpdate = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePro
|
|
|
61052
62824
|
getCliUpdateStatus,
|
|
61053
62825
|
isNewerVersion
|
|
61054
62826
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
62827
|
+
const defaultDeps = {
|
|
62828
|
+
installDependencies: (args) => startProjectInstall(args),
|
|
62829
|
+
applyEnv: (args) => connectionTools.apply_env(args)
|
|
62830
|
+
};
|
|
62831
|
+
const DISPATCH = {
|
|
62832
|
+
// `readiness/deps.ts` → installFixFor. Returns the moment the child has a pid: a
|
|
62833
|
+
// cold monorepo install outlives the desktop relay's 15s command timeout
|
|
62834
|
+
// (`relay_client.rs:29`), so a blocking call here would report failure for an
|
|
62835
|
+
// install that is running perfectly well, and the user would start a second one on
|
|
62836
|
+
// top of it. Progress arrives on `install:log`; completion is a state transition.
|
|
62837
|
+
install_dependencies: {
|
|
62838
|
+
projectArg: "project",
|
|
62839
|
+
run: (args, deps) => deps.installDependencies(args)
|
|
62840
|
+
},
|
|
62841
|
+
// `readiness/env-owned.ts` → applyEnvFix. Not staged: `apply_env` is absent from
|
|
62842
|
+
// `DESTRUCTIVE_TOOLS`, it backs the file up before writing (`writeWithBackup`) and
|
|
62843
|
+
// it merges rather than replaces, and the GUI's confirmation is the click itself —
|
|
62844
|
+
// the same footing as every other button on this card.
|
|
62845
|
+
apply_env: {
|
|
62846
|
+
projectArg: "project_id",
|
|
62847
|
+
run: (args, deps) => deps.applyEnv(
|
|
62848
|
+
args
|
|
62849
|
+
)
|
|
62850
|
+
}
|
|
62851
|
+
};
|
|
62852
|
+
const READINESS_FIX_TOOLS = Object.freeze(Object.keys(DISPATCH));
|
|
62853
|
+
async function applyReadinessFix(input, deps = defaultDeps) {
|
|
62854
|
+
const fix = input.fix ?? {};
|
|
62855
|
+
const entry = Object.prototype.hasOwnProperty.call(DISPATCH, fix.tool) ? DISPATCH[fix.tool] : void 0;
|
|
62856
|
+
if (!entry) {
|
|
62857
|
+
throw new McpError(
|
|
62858
|
+
"invalid_args",
|
|
62859
|
+
`"${fix.tool}" is not a readiness fix tool. A readiness finding's fix may name ${READINESS_FIX_TOOLS.join(" or ")}; pass the finding's own \`fix\` through unchanged rather than composing one.`
|
|
62860
|
+
);
|
|
62861
|
+
}
|
|
62862
|
+
const args = fix.args ?? {};
|
|
62863
|
+
if (input.projectId !== void 0) {
|
|
62864
|
+
const named = args[entry.projectArg];
|
|
62865
|
+
if (named !== input.projectId) {
|
|
62866
|
+
throw new McpError(
|
|
62867
|
+
"invalid_args",
|
|
62868
|
+
`This ${fix.tool} fix names project "${String(named)}", not "${input.projectId}". A readiness fix belongs to the report it came from; applying it to another project would act on the wrong tree. Re-read the readiness report for the project you meant and use ITS descriptor.`
|
|
62869
|
+
);
|
|
62870
|
+
}
|
|
62871
|
+
}
|
|
62872
|
+
return entry.run(args, deps);
|
|
62873
|
+
}
|
|
62874
|
+
const readinessFix = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
62875
|
+
__proto__: null,
|
|
62876
|
+
READINESS_FIX_TOOLS,
|
|
62877
|
+
applyReadinessFix
|
|
62878
|
+
}, Symbol.toStringTag, { value: "Module" }));
|