supbuddy 3.2.15 → 3.2.16
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 +754 -488
- package/dist/daemon/worker.cjs +531 -95
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -9255,11 +9255,11 @@ var require_mime_types = __commonJS({
|
|
|
9255
9255
|
}
|
|
9256
9256
|
return exts[0];
|
|
9257
9257
|
}
|
|
9258
|
-
function lookup(
|
|
9259
|
-
if (!
|
|
9258
|
+
function lookup(path40) {
|
|
9259
|
+
if (!path40 || typeof path40 !== "string") {
|
|
9260
9260
|
return false;
|
|
9261
9261
|
}
|
|
9262
|
-
var extension2 = extname("x." +
|
|
9262
|
+
var extension2 = extname("x." + path40).toLowerCase().substr(1);
|
|
9263
9263
|
if (!extension2) {
|
|
9264
9264
|
return false;
|
|
9265
9265
|
}
|
|
@@ -12660,11 +12660,11 @@ var require_server = __commonJS({
|
|
|
12660
12660
|
* @protected
|
|
12661
12661
|
*/
|
|
12662
12662
|
_computePath(options) {
|
|
12663
|
-
let
|
|
12663
|
+
let path40 = (options.path || "/engine.io").replace(/\/$/, "");
|
|
12664
12664
|
if (options.addTrailingSlash !== false) {
|
|
12665
|
-
|
|
12665
|
+
path40 += "/";
|
|
12666
12666
|
}
|
|
12667
|
-
return
|
|
12667
|
+
return path40;
|
|
12668
12668
|
}
|
|
12669
12669
|
/**
|
|
12670
12670
|
* Returns a list of available transports for upgrade given a certain transport.
|
|
@@ -13180,10 +13180,10 @@ var require_server = __commonJS({
|
|
|
13180
13180
|
* @param {Object} options
|
|
13181
13181
|
*/
|
|
13182
13182
|
attach(server, options = {}) {
|
|
13183
|
-
const
|
|
13183
|
+
const path40 = this._computePath(options);
|
|
13184
13184
|
const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1e3;
|
|
13185
13185
|
function check(req) {
|
|
13186
|
-
return
|
|
13186
|
+
return path40 === req.url.slice(0, path40.length);
|
|
13187
13187
|
}
|
|
13188
13188
|
const listeners = server.listeners("request").slice(0);
|
|
13189
13189
|
server.removeAllListeners("request");
|
|
@@ -13191,7 +13191,7 @@ var require_server = __commonJS({
|
|
|
13191
13191
|
server.on("listening", this.init.bind(this));
|
|
13192
13192
|
server.on("request", (req, res) => {
|
|
13193
13193
|
if (check(req)) {
|
|
13194
|
-
debug('intercepting request for path "%s"',
|
|
13194
|
+
debug('intercepting request for path "%s"', path40);
|
|
13195
13195
|
this.handleRequest(req, res);
|
|
13196
13196
|
} else {
|
|
13197
13197
|
let i = 0;
|
|
@@ -14031,8 +14031,8 @@ var require_userver = __commonJS({
|
|
|
14031
14031
|
* @param options
|
|
14032
14032
|
*/
|
|
14033
14033
|
attach(app, options = {}) {
|
|
14034
|
-
const
|
|
14035
|
-
app.any(
|
|
14034
|
+
const path40 = this._computePath(options);
|
|
14035
|
+
app.any(path40, this.handleRequest.bind(this)).ws(path40, {
|
|
14036
14036
|
compression: options.compression,
|
|
14037
14037
|
idleTimeout: options.idleTimeout,
|
|
14038
14038
|
maxBackpressure: options.maxBackpressure,
|
|
@@ -18461,7 +18461,7 @@ var require_dist2 = __commonJS({
|
|
|
18461
18461
|
var zlib_1 = __require("zlib");
|
|
18462
18462
|
var accepts = require_accepts();
|
|
18463
18463
|
var stream_1 = __require("stream");
|
|
18464
|
-
var
|
|
18464
|
+
var path40 = __require("path");
|
|
18465
18465
|
var engine_io_1 = require_engine_io();
|
|
18466
18466
|
var client_1 = require_client();
|
|
18467
18467
|
var events_1 = __require("events");
|
|
@@ -18656,7 +18656,7 @@ var require_dist2 = __commonJS({
|
|
|
18656
18656
|
res.writeHeader("cache-control", "public, max-age=0");
|
|
18657
18657
|
res.writeHeader("content-type", "application/" + (isMap ? "json" : "javascript") + "; charset=utf-8");
|
|
18658
18658
|
res.writeHeader("etag", expectedEtag);
|
|
18659
|
-
const filepath =
|
|
18659
|
+
const filepath = path40.join(__dirname, "../client-dist/", filename);
|
|
18660
18660
|
(0, uws_1.serveFile)(res, filepath);
|
|
18661
18661
|
});
|
|
18662
18662
|
}
|
|
@@ -18738,7 +18738,7 @@ var require_dist2 = __commonJS({
|
|
|
18738
18738
|
* @private
|
|
18739
18739
|
*/
|
|
18740
18740
|
static sendFile(filename, req, res) {
|
|
18741
|
-
const readStream = (0, fs_1.createReadStream)(
|
|
18741
|
+
const readStream = (0, fs_1.createReadStream)(path40.join(__dirname, "../client-dist/", filename));
|
|
18742
18742
|
const encoding = accepts(req).encodings(["br", "gzip", "deflate"]);
|
|
18743
18743
|
const onError = (err) => {
|
|
18744
18744
|
if (err) {
|
|
@@ -19807,7 +19807,7 @@ function cliBuildKind(env3 = process.env) {
|
|
|
19807
19807
|
return raw === "host" || raw === "npm" ? raw : "dev";
|
|
19808
19808
|
}
|
|
19809
19809
|
function cliVersion(env3 = process.env) {
|
|
19810
|
-
return "3.2.
|
|
19810
|
+
return "3.2.16".trim() || "0.0.0-dev";
|
|
19811
19811
|
}
|
|
19812
19812
|
function isDevBuild(env3) {
|
|
19813
19813
|
return cliBuildKind(env3) === "dev";
|
|
@@ -20646,8 +20646,8 @@ var init_parseUtil = __esm({
|
|
|
20646
20646
|
init_errors();
|
|
20647
20647
|
init_en();
|
|
20648
20648
|
makeIssue = (params) => {
|
|
20649
|
-
const { data, path:
|
|
20650
|
-
const fullPath = [...
|
|
20649
|
+
const { data, path: path40, errorMaps, issueData } = params;
|
|
20650
|
+
const fullPath = [...path40, ...issueData.path || []];
|
|
20651
20651
|
const fullIssue = {
|
|
20652
20652
|
...issueData,
|
|
20653
20653
|
path: fullPath
|
|
@@ -20958,11 +20958,11 @@ var init_types2 = __esm({
|
|
|
20958
20958
|
init_parseUtil();
|
|
20959
20959
|
init_util();
|
|
20960
20960
|
ParseInputLazyPath = class {
|
|
20961
|
-
constructor(parent, value,
|
|
20961
|
+
constructor(parent, value, path40, key) {
|
|
20962
20962
|
this._cachedPath = [];
|
|
20963
20963
|
this.parent = parent;
|
|
20964
20964
|
this.data = value;
|
|
20965
|
-
this._path =
|
|
20965
|
+
this._path = path40;
|
|
20966
20966
|
this._key = key;
|
|
20967
20967
|
}
|
|
20968
20968
|
get path() {
|
|
@@ -28329,14 +28329,22 @@ var init_store = __esm({
|
|
|
28329
28329
|
schedulePersist();
|
|
28330
28330
|
},
|
|
28331
28331
|
refreshSubdomainMappings: (projectId, newMappings) => {
|
|
28332
|
-
set2((state) =>
|
|
28333
|
-
|
|
28334
|
-
|
|
28335
|
-
|
|
28336
|
-
|
|
28337
|
-
|
|
28338
|
-
|
|
28339
|
-
|
|
28332
|
+
set2((state) => {
|
|
28333
|
+
const previous = new Map(
|
|
28334
|
+
state.mappings.filter((m) => m.projectId === projectId && m.autoGenerated).map((m) => [m.domain, m])
|
|
28335
|
+
);
|
|
28336
|
+
return {
|
|
28337
|
+
mappings: [
|
|
28338
|
+
...state.mappings.filter(
|
|
28339
|
+
(m) => !(m.projectId === projectId && m.autoGenerated)
|
|
28340
|
+
),
|
|
28341
|
+
...newMappings.map((m) => {
|
|
28342
|
+
const prior = previous.get(m.domain);
|
|
28343
|
+
return prior ? { ...m, id: prior.id, createdAt: prior.createdAt, enabled: prior.enabled } : m;
|
|
28344
|
+
})
|
|
28345
|
+
]
|
|
28346
|
+
};
|
|
28347
|
+
});
|
|
28340
28348
|
schedulePersist();
|
|
28341
28349
|
},
|
|
28342
28350
|
mcpClients: [],
|
|
@@ -32620,12 +32628,71 @@ var init_script_manager = __esm({
|
|
|
32620
32628
|
}
|
|
32621
32629
|
});
|
|
32622
32630
|
|
|
32631
|
+
// ../../packages/core/supabase-ports.ts
|
|
32632
|
+
function detectMailSection(configToml) {
|
|
32633
|
+
for (const name of MAIL_SECTION_PRECEDENCE) {
|
|
32634
|
+
if (new RegExp(`^[ \\t]*\\[${name}\\][ \\t]*(?:#.*)?$`, "m").test(configToml)) return name;
|
|
32635
|
+
}
|
|
32636
|
+
return null;
|
|
32637
|
+
}
|
|
32638
|
+
function mailKeyForSection(key, section) {
|
|
32639
|
+
if (!section || !key.startsWith("inbucket.")) return key;
|
|
32640
|
+
return `${section}.${key.slice("inbucket.".length)}`;
|
|
32641
|
+
}
|
|
32642
|
+
async function allocateSupabaseBlock(reservedBases, isFree) {
|
|
32643
|
+
for (let base = RANGE_START; base <= RANGE_END; base += BLOCK_SPAN) {
|
|
32644
|
+
if (reservedBases.has(base)) continue;
|
|
32645
|
+
let ok = true;
|
|
32646
|
+
for (let p = base; p < base + BLOCK_SPAN; p++) {
|
|
32647
|
+
if (!await isFree(p)) {
|
|
32648
|
+
ok = false;
|
|
32649
|
+
break;
|
|
32650
|
+
}
|
|
32651
|
+
}
|
|
32652
|
+
if (!ok) continue;
|
|
32653
|
+
return Object.fromEntries(SUPABASE_PORT_KEYS.map(({ key, stock }) => [key, base + (stock - STOCK_BASE)]));
|
|
32654
|
+
}
|
|
32655
|
+
return null;
|
|
32656
|
+
}
|
|
32657
|
+
function supabaseManagedProjectId(name, idShort) {
|
|
32658
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
32659
|
+
const id = idShort.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
32660
|
+
return slug ? `sb-${slug}-${id}` : `sb-${id}`;
|
|
32661
|
+
}
|
|
32662
|
+
function remapVolumeName(volume, oldId, newId) {
|
|
32663
|
+
return volume.endsWith(oldId) ? volume.slice(0, volume.length - oldId.length) + newId : volume;
|
|
32664
|
+
}
|
|
32665
|
+
var MAIL_SECTION_PRECEDENCE, MAIL_STOCK_PORTS, SUPABASE_PORT_KEYS, STOCK_BASE, BLOCK_SPAN, RANGE_START, RANGE_END;
|
|
32666
|
+
var init_supabase_ports = __esm({
|
|
32667
|
+
"../../packages/core/supabase-ports.ts"() {
|
|
32668
|
+
"use strict";
|
|
32669
|
+
MAIL_SECTION_PRECEDENCE = ["local_smtp", "inbucket"];
|
|
32670
|
+
MAIL_STOCK_PORTS = { port: 54324, smtp_port: 54325, pop3_port: 54326 };
|
|
32671
|
+
SUPABASE_PORT_KEYS = [
|
|
32672
|
+
{ key: "db.shadow_port", stock: 54320 },
|
|
32673
|
+
{ key: "api.port", stock: 54321 },
|
|
32674
|
+
{ key: "db.port", stock: 54322 },
|
|
32675
|
+
{ key: "studio.port", stock: 54323 },
|
|
32676
|
+
{ key: "inbucket.port", stock: MAIL_STOCK_PORTS.port },
|
|
32677
|
+
{ key: "inbucket.smtp_port", stock: MAIL_STOCK_PORTS.smtp_port },
|
|
32678
|
+
{ key: "inbucket.pop3_port", stock: MAIL_STOCK_PORTS.pop3_port },
|
|
32679
|
+
{ key: "analytics.port", stock: 54327 },
|
|
32680
|
+
{ key: "db.pooler.port", stock: 54329 }
|
|
32681
|
+
];
|
|
32682
|
+
STOCK_BASE = 54320;
|
|
32683
|
+
BLOCK_SPAN = 10;
|
|
32684
|
+
RANGE_START = 55e3;
|
|
32685
|
+
RANGE_END = 59990;
|
|
32686
|
+
}
|
|
32687
|
+
});
|
|
32688
|
+
|
|
32623
32689
|
// ../../packages/core/project-scanner.ts
|
|
32624
32690
|
var init_project_scanner = __esm({
|
|
32625
32691
|
"../../packages/core/project-scanner.ts"() {
|
|
32626
32692
|
"use strict";
|
|
32627
32693
|
init_compose_scanner();
|
|
32628
32694
|
init_script_manager();
|
|
32695
|
+
init_supabase_ports();
|
|
32629
32696
|
}
|
|
32630
32697
|
});
|
|
32631
32698
|
|
|
@@ -34891,7 +34958,7 @@ var require_multicast_dns = __commonJS({
|
|
|
34891
34958
|
var dgram = __require("dgram");
|
|
34892
34959
|
var thunky = require_thunky();
|
|
34893
34960
|
var events = __require("events");
|
|
34894
|
-
var
|
|
34961
|
+
var os15 = __require("os");
|
|
34895
34962
|
var noop = function() {
|
|
34896
34963
|
};
|
|
34897
34964
|
module.exports = function(opts) {
|
|
@@ -35021,14 +35088,14 @@ var require_multicast_dns = __commonJS({
|
|
|
35021
35088
|
return that;
|
|
35022
35089
|
};
|
|
35023
35090
|
function defaultInterface() {
|
|
35024
|
-
var networks =
|
|
35091
|
+
var networks = os15.networkInterfaces();
|
|
35025
35092
|
var names = Object.keys(networks);
|
|
35026
35093
|
for (var i = 0; i < names.length; i++) {
|
|
35027
35094
|
var net2 = networks[names[i]];
|
|
35028
35095
|
for (var j = 0; j < net2.length; j++) {
|
|
35029
35096
|
var iface = net2[j];
|
|
35030
35097
|
if (isIPv4(iface.family) && !iface.internal) {
|
|
35031
|
-
if (
|
|
35098
|
+
if (os15.platform() === "darwin" && names[i] === "en0") return iface.address;
|
|
35032
35099
|
return "0.0.0.0";
|
|
35033
35100
|
}
|
|
35034
35101
|
}
|
|
@@ -35036,7 +35103,7 @@ var require_multicast_dns = __commonJS({
|
|
|
35036
35103
|
return "127.0.0.1";
|
|
35037
35104
|
}
|
|
35038
35105
|
function allInterfaces() {
|
|
35039
|
-
var networks =
|
|
35106
|
+
var networks = os15.networkInterfaces();
|
|
35040
35107
|
var names = Object.keys(networks);
|
|
35041
35108
|
var res = [];
|
|
35042
35109
|
for (var i = 0; i < names.length; i++) {
|
|
@@ -35088,149 +35155,6 @@ var init_mapping_scope = __esm({
|
|
|
35088
35155
|
}
|
|
35089
35156
|
});
|
|
35090
35157
|
|
|
35091
|
-
// ../../packages/core/dns-platform.ts
|
|
35092
|
-
import fs12 from "fs/promises";
|
|
35093
|
-
import path15 from "path";
|
|
35094
|
-
function getManagedDomainSuffixes() {
|
|
35095
|
-
const store = useStore2.getState();
|
|
35096
|
-
const suffixes = /* @__PURE__ */ new Set();
|
|
35097
|
-
for (const mapping of store.mappings) {
|
|
35098
|
-
if (!isMappingServeable(mapping, store.getProject)) continue;
|
|
35099
|
-
const parts = mapping.domain.split(".").filter(Boolean);
|
|
35100
|
-
if (parts.length < 2) continue;
|
|
35101
|
-
const suffix = parts.length >= 3 ? parts.slice(1).join(".") : parts.join(".");
|
|
35102
|
-
suffixes.add(suffix);
|
|
35103
|
-
}
|
|
35104
|
-
return Array.from(suffixes);
|
|
35105
|
-
}
|
|
35106
|
-
async function buildMacOsCleanupCommand() {
|
|
35107
|
-
const resolverDir = "/etc/resolver";
|
|
35108
|
-
try {
|
|
35109
|
-
const files = await fs12.readdir(resolverDir);
|
|
35110
|
-
const toRemove = [];
|
|
35111
|
-
for (const file of files) {
|
|
35112
|
-
const filePath = path15.join(resolverDir, file);
|
|
35113
|
-
try {
|
|
35114
|
-
const content = await fs12.readFile(filePath, "utf-8");
|
|
35115
|
-
if (content.includes(SUPBUDDY_MARKER)) {
|
|
35116
|
-
toRemove.push(`rm "${filePath}"`);
|
|
35117
|
-
}
|
|
35118
|
-
} catch {
|
|
35119
|
-
}
|
|
35120
|
-
}
|
|
35121
|
-
if (toRemove.length === 0) return null;
|
|
35122
|
-
return toRemove.join(" && ");
|
|
35123
|
-
} catch {
|
|
35124
|
-
return null;
|
|
35125
|
-
}
|
|
35126
|
-
}
|
|
35127
|
-
function buildLinuxCleanupCommand() {
|
|
35128
|
-
const configPath = "/etc/systemd/resolved.conf.d/supbuddy.conf";
|
|
35129
|
-
return `rm -f "${configPath}" && systemctl restart systemd-resolved 2>/dev/null || true`;
|
|
35130
|
-
}
|
|
35131
|
-
function buildWindowsCleanupCommand() {
|
|
35132
|
-
return `for /f "tokens=*" %k in ('reg query "HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DnsPolicyConfig" /s ^| findstr "Supbuddy-"') do reg delete "%k" /f 2>nul`;
|
|
35133
|
-
}
|
|
35134
|
-
async function buildPlatformDnsCleanupCommand() {
|
|
35135
|
-
const platform = process.platform;
|
|
35136
|
-
let command = null;
|
|
35137
|
-
switch (platform) {
|
|
35138
|
-
case "darwin":
|
|
35139
|
-
command = await buildMacOsCleanupCommand();
|
|
35140
|
-
break;
|
|
35141
|
-
case "linux":
|
|
35142
|
-
command = buildLinuxCleanupCommand();
|
|
35143
|
-
break;
|
|
35144
|
-
case "win32":
|
|
35145
|
-
command = buildWindowsCleanupCommand();
|
|
35146
|
-
break;
|
|
35147
|
-
default:
|
|
35148
|
-
return null;
|
|
35149
|
-
}
|
|
35150
|
-
if (!command) return null;
|
|
35151
|
-
return {
|
|
35152
|
-
command,
|
|
35153
|
-
prompt: "Supbuddy needs administrator access to remove DNS configuration."
|
|
35154
|
-
};
|
|
35155
|
-
}
|
|
35156
|
-
async function auditResolverState() {
|
|
35157
|
-
const expected = getManagedDomainSuffixes();
|
|
35158
|
-
const platform = process.platform;
|
|
35159
|
-
if (platform === "darwin") {
|
|
35160
|
-
return auditMacOsResolver(expected);
|
|
35161
|
-
}
|
|
35162
|
-
if (platform === "linux") {
|
|
35163
|
-
return auditLinuxResolver(expected);
|
|
35164
|
-
}
|
|
35165
|
-
return { in_sync: null, missing: [], extra: [], expected };
|
|
35166
|
-
}
|
|
35167
|
-
async function auditMacOsResolver(expected) {
|
|
35168
|
-
const resolverDir = "/etc/resolver";
|
|
35169
|
-
let entries;
|
|
35170
|
-
try {
|
|
35171
|
-
entries = await fs12.readdir(resolverDir);
|
|
35172
|
-
} catch (err) {
|
|
35173
|
-
if (err.code === "ENOENT") {
|
|
35174
|
-
return {
|
|
35175
|
-
in_sync: expected.length === 0,
|
|
35176
|
-
missing: expected,
|
|
35177
|
-
extra: [],
|
|
35178
|
-
expected
|
|
35179
|
-
};
|
|
35180
|
-
}
|
|
35181
|
-
return { in_sync: null, missing: [], extra: [], expected, error: err.message };
|
|
35182
|
-
}
|
|
35183
|
-
const present = [];
|
|
35184
|
-
for (const file of entries) {
|
|
35185
|
-
try {
|
|
35186
|
-
const content = await fs12.readFile(path15.join(resolverDir, file), "utf-8");
|
|
35187
|
-
if (content.includes(SUPBUDDY_MARKER)) present.push(file);
|
|
35188
|
-
} catch {
|
|
35189
|
-
}
|
|
35190
|
-
}
|
|
35191
|
-
const expectedSet = new Set(expected);
|
|
35192
|
-
const presentSet = new Set(present);
|
|
35193
|
-
const missing = expected.filter((s) => !presentSet.has(s));
|
|
35194
|
-
const extra = present.filter((s) => !expectedSet.has(s));
|
|
35195
|
-
return { in_sync: missing.length === 0 && extra.length === 0, missing, extra, expected };
|
|
35196
|
-
}
|
|
35197
|
-
async function auditLinuxResolver(expected) {
|
|
35198
|
-
const configPath = "/etc/systemd/resolved.conf.d/supbuddy.conf";
|
|
35199
|
-
let content;
|
|
35200
|
-
try {
|
|
35201
|
-
content = await fs12.readFile(configPath, "utf-8");
|
|
35202
|
-
} catch (err) {
|
|
35203
|
-
if (err.code === "ENOENT") {
|
|
35204
|
-
return {
|
|
35205
|
-
in_sync: expected.length === 0,
|
|
35206
|
-
missing: expected,
|
|
35207
|
-
extra: [],
|
|
35208
|
-
expected
|
|
35209
|
-
};
|
|
35210
|
-
}
|
|
35211
|
-
return { in_sync: null, missing: [], extra: [], expected, error: err.message };
|
|
35212
|
-
}
|
|
35213
|
-
if (!content.includes(SUPBUDDY_MARKER)) {
|
|
35214
|
-
return { in_sync: false, missing: expected, extra: [], expected };
|
|
35215
|
-
}
|
|
35216
|
-
const match = content.match(/^Domains=(.*)$/m);
|
|
35217
|
-
const present = match ? match[1].split(/\s+/).map((d) => d.replace(/^~/, "").trim()).filter(Boolean) : [];
|
|
35218
|
-
const expectedSet = new Set(expected);
|
|
35219
|
-
const presentSet = new Set(present);
|
|
35220
|
-
const missing = expected.filter((s) => !presentSet.has(s));
|
|
35221
|
-
const extra = present.filter((s) => !expectedSet.has(s));
|
|
35222
|
-
return { in_sync: missing.length === 0 && extra.length === 0, missing, extra, expected };
|
|
35223
|
-
}
|
|
35224
|
-
var SUPBUDDY_MARKER;
|
|
35225
|
-
var init_dns_platform = __esm({
|
|
35226
|
-
"../../packages/core/dns-platform.ts"() {
|
|
35227
|
-
"use strict";
|
|
35228
|
-
init_store();
|
|
35229
|
-
init_mapping_scope();
|
|
35230
|
-
SUPBUDDY_MARKER = "# Managed by Supbuddy";
|
|
35231
|
-
}
|
|
35232
|
-
});
|
|
35233
|
-
|
|
35234
35158
|
// ../../node_modules/.pnpm/dns2@2.4.0/node_modules/dns2/lib/reader.js
|
|
35235
35159
|
var require_reader = __commonJS({
|
|
35236
35160
|
"../../node_modules/.pnpm/dns2@2.4.0/node_modules/dns2/lib/reader.js"(exports, module) {
|
|
@@ -37421,6 +37345,32 @@ var init_dns2 = __esm({
|
|
|
37421
37345
|
});
|
|
37422
37346
|
|
|
37423
37347
|
// ../../packages/core/dns-server.ts
|
|
37348
|
+
function listAuthoritativeNames() {
|
|
37349
|
+
const store = useStore2.getState();
|
|
37350
|
+
const out = [];
|
|
37351
|
+
for (const mapping of store.mappings) {
|
|
37352
|
+
if (!mapping.enabled) continue;
|
|
37353
|
+
out.push({ name: mapping.domain.toLowerCase(), served: isMappingServeable(mapping, store.getProject) });
|
|
37354
|
+
}
|
|
37355
|
+
for (const project of store.projects) {
|
|
37356
|
+
const base = project.domain?.toLowerCase();
|
|
37357
|
+
if (!base) continue;
|
|
37358
|
+
out.push({ name: base, served: project.enabled === true });
|
|
37359
|
+
}
|
|
37360
|
+
return out;
|
|
37361
|
+
}
|
|
37362
|
+
function listServedNames() {
|
|
37363
|
+
return Array.from(new Set(listAuthoritativeNames().filter((e) => e.served).map((e) => e.name)));
|
|
37364
|
+
}
|
|
37365
|
+
function authoritativeAddresses() {
|
|
37366
|
+
return [TYPE_A, TYPE_AAAA].map((qtype) => planDnsAnswer(qtype, true)).flatMap((plan2) => plan2.kind === "answer" ? [plan2.address] : []);
|
|
37367
|
+
}
|
|
37368
|
+
function planDnsAnswer(qtype, isMapped) {
|
|
37369
|
+
if (!isMapped) return { kind: "forward" };
|
|
37370
|
+
if (qtype === TYPE_A) return { kind: "answer", rrtype: TYPE_A, address: "127.0.0.1" };
|
|
37371
|
+
if (qtype === TYPE_AAAA) return { kind: "answer", rrtype: TYPE_AAAA, address: "::1" };
|
|
37372
|
+
return { kind: "nodata" };
|
|
37373
|
+
}
|
|
37424
37374
|
async function stopDnsServer() {
|
|
37425
37375
|
if (!socket) return;
|
|
37426
37376
|
return new Promise((resolve) => {
|
|
@@ -37432,55 +37382,333 @@ async function stopDnsServer() {
|
|
|
37432
37382
|
});
|
|
37433
37383
|
});
|
|
37434
37384
|
}
|
|
37435
|
-
var Packet2, socket;
|
|
37385
|
+
var Packet2, socket, TYPE_A, TYPE_AAAA;
|
|
37436
37386
|
var init_dns_server = __esm({
|
|
37437
37387
|
"../../packages/core/dns-server.ts"() {
|
|
37438
37388
|
"use strict";
|
|
37439
37389
|
init_dns2();
|
|
37440
37390
|
init_store();
|
|
37391
|
+
init_mapping_scope();
|
|
37441
37392
|
({ Packet: Packet2 } = dns2_default);
|
|
37442
37393
|
socket = null;
|
|
37394
|
+
TYPE_A = 1;
|
|
37395
|
+
TYPE_AAAA = 28;
|
|
37443
37396
|
}
|
|
37444
37397
|
});
|
|
37445
37398
|
|
|
37446
|
-
// ../../packages/core/
|
|
37447
|
-
|
|
37399
|
+
// ../../packages/core/hosts-fallback.ts
|
|
37400
|
+
var hosts_fallback_exports = {};
|
|
37401
|
+
__export(hosts_fallback_exports, {
|
|
37402
|
+
HOSTS_FALLBACK_MARKER_END: () => HOSTS_FALLBACK_MARKER_END,
|
|
37403
|
+
HOSTS_FALLBACK_MARKER_START: () => HOSTS_FALLBACK_MARKER_START,
|
|
37404
|
+
applyHostsFallbackBlock: () => applyHostsFallbackBlock,
|
|
37405
|
+
buildHostsFallbackBlock: () => buildHostsFallbackBlock,
|
|
37406
|
+
buildHostsFallbackRemoveCommand: () => buildHostsFallbackRemoveCommand,
|
|
37407
|
+
buildHostsInstallCommand: () => buildHostsInstallCommand,
|
|
37408
|
+
decideHostsFallback: () => decideHostsFallback,
|
|
37409
|
+
hasHostsFallbackBlock: () => hasHostsFallbackBlock,
|
|
37410
|
+
hostsFallbackDrifted: () => hostsFallbackDrifted,
|
|
37411
|
+
hostsFallbackEntries: () => hostsFallbackEntries,
|
|
37412
|
+
planHostsFallbackWrite: () => planHostsFallbackWrite,
|
|
37413
|
+
renderHostsFallbackBlock: () => renderHostsFallbackBlock,
|
|
37414
|
+
stripHostsFallbackBlock: () => stripHostsFallbackBlock
|
|
37415
|
+
});
|
|
37416
|
+
import fs12 from "fs/promises";
|
|
37417
|
+
import os9 from "os";
|
|
37418
|
+
import path15 from "path";
|
|
37419
|
+
function hostsFallbackEntries() {
|
|
37420
|
+
const addresses = authoritativeAddresses();
|
|
37421
|
+
return listServedNames().map((name) => ({ name, addresses }));
|
|
37422
|
+
}
|
|
37423
|
+
function renderHostsFallbackBlock(entries) {
|
|
37424
|
+
const lines = [HOSTS_FALLBACK_MARKER_START, ...BLOCK_NOTE];
|
|
37425
|
+
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
37426
|
+
for (const address of entry.addresses) lines.push(`${address} ${entry.name}`);
|
|
37427
|
+
}
|
|
37428
|
+
lines.push(HOSTS_FALLBACK_MARKER_END);
|
|
37429
|
+
return lines.join("\n");
|
|
37430
|
+
}
|
|
37431
|
+
function buildHostsFallbackBlock() {
|
|
37432
|
+
const entries = hostsFallbackEntries();
|
|
37433
|
+
if (entries.length === 0) return null;
|
|
37434
|
+
return renderHostsFallbackBlock(entries);
|
|
37435
|
+
}
|
|
37436
|
+
function stripHostsFallbackBlock(content) {
|
|
37437
|
+
const out = [];
|
|
37438
|
+
let inBlock = false;
|
|
37439
|
+
for (const line of content.split("\n")) {
|
|
37440
|
+
const trimmed = line.trim();
|
|
37441
|
+
if (trimmed === HOSTS_FALLBACK_MARKER_START) {
|
|
37442
|
+
inBlock = true;
|
|
37443
|
+
continue;
|
|
37444
|
+
}
|
|
37445
|
+
if (trimmed === HOSTS_FALLBACK_MARKER_END) {
|
|
37446
|
+
inBlock = false;
|
|
37447
|
+
continue;
|
|
37448
|
+
}
|
|
37449
|
+
if (!inBlock) out.push(line);
|
|
37450
|
+
}
|
|
37451
|
+
return out.join("\n");
|
|
37452
|
+
}
|
|
37453
|
+
function applyHostsFallbackBlock(content, block) {
|
|
37454
|
+
const base = stripHostsFallbackBlock(content).replace(/\n+$/, "");
|
|
37455
|
+
if (!block) return base === "" ? "" : `${base}
|
|
37456
|
+
`;
|
|
37457
|
+
const body = block.replace(/\n+$/, "");
|
|
37458
|
+
return base === "" ? `${body}
|
|
37459
|
+
` : `${base}
|
|
37460
|
+
|
|
37461
|
+
${body}
|
|
37462
|
+
`;
|
|
37463
|
+
}
|
|
37464
|
+
async function readHostsFile() {
|
|
37465
|
+
try {
|
|
37466
|
+
return await fs12.readFile(HOSTS_PATH, "utf-8");
|
|
37467
|
+
} catch {
|
|
37468
|
+
return null;
|
|
37469
|
+
}
|
|
37470
|
+
}
|
|
37471
|
+
async function hasHostsFallbackBlock() {
|
|
37472
|
+
const content = await readHostsFile();
|
|
37473
|
+
return content != null && content.includes(HOSTS_FALLBACK_MARKER_START);
|
|
37474
|
+
}
|
|
37475
|
+
async function hostsFallbackDrifted(target = "block") {
|
|
37476
|
+
const current = await readHostsFile();
|
|
37477
|
+
if (current == null) return false;
|
|
37478
|
+
return applyHostsFallbackBlock(current, target === "block" ? buildHostsFallbackBlock() : null) !== current;
|
|
37479
|
+
}
|
|
37480
|
+
async function buildHostsInstallCommand(content) {
|
|
37481
|
+
const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
37482
|
+
const tmpPath = path15.join(os9.tmpdir(), `supbuddy-hosts-${token}`);
|
|
37483
|
+
await fs12.writeFile(tmpPath, content, "utf-8");
|
|
37484
|
+
const staged = `/etc/.hosts.supbuddy.${token}`;
|
|
37485
|
+
const install = `{ { [ -f "${HOSTS_BACKUP_PATH}" ] || cp -p "${HOSTS_PATH}" "${HOSTS_BACKUP_PATH}"; } && cp "${tmpPath}" "${staged}" && chmod 644 "${staged}" && chown root:wheel "${staged}" && mv -f "${staged}" "${HOSTS_PATH}" || { rm -f "${staged}"; false; }; }`;
|
|
37486
|
+
const flush = "{ dscacheutil -flushcache 2>/dev/null; killall -HUP mDNSResponder 2>/dev/null; true; }";
|
|
37487
|
+
return { command: `${install} && ${flush}`, tmpFiles: [tmpPath] };
|
|
37488
|
+
}
|
|
37489
|
+
function buildHostsFallbackRemoveCommand() {
|
|
37490
|
+
return `sed -i '' '/^${HOSTS_FALLBACK_MARKER_START}$/,/^${HOSTS_FALLBACK_MARKER_END}$/d' "${HOSTS_PATH}"`;
|
|
37491
|
+
}
|
|
37492
|
+
async function planHostsFallbackWrite(target) {
|
|
37493
|
+
const current = await readHostsFile();
|
|
37494
|
+
if (current == null) return null;
|
|
37495
|
+
const next = applyHostsFallbackBlock(current, target === "block" ? buildHostsFallbackBlock() : null);
|
|
37496
|
+
if (next === current) return null;
|
|
37497
|
+
const built = await buildHostsInstallCommand(next);
|
|
37498
|
+
return { ...built, content: next };
|
|
37499
|
+
}
|
|
37500
|
+
function decideHostsFallback(input) {
|
|
37501
|
+
if (input.platform !== "darwin") return "idle";
|
|
37502
|
+
if (input.resolverPathLoaded === true) return input.active ? "release" : "idle";
|
|
37503
|
+
if (input.active) return "keep";
|
|
37504
|
+
if (input.resolverPathLoaded === false && input.resolverFilesInSync === true) return "engage";
|
|
37505
|
+
return "idle";
|
|
37506
|
+
}
|
|
37507
|
+
var HOSTS_FALLBACK_MARKER_START, HOSTS_FALLBACK_MARKER_END, HOSTS_PATH, HOSTS_BACKUP_PATH, BLOCK_NOTE;
|
|
37508
|
+
var init_hosts_fallback = __esm({
|
|
37509
|
+
"../../packages/core/hosts-fallback.ts"() {
|
|
37510
|
+
"use strict";
|
|
37511
|
+
init_dns_server();
|
|
37512
|
+
HOSTS_FALLBACK_MARKER_START = "# Supbuddy DNS fallback - Start";
|
|
37513
|
+
HOSTS_FALLBACK_MARKER_END = "# Supbuddy DNS fallback - End";
|
|
37514
|
+
HOSTS_PATH = "/etc/hosts";
|
|
37515
|
+
HOSTS_BACKUP_PATH = "/etc/hosts.supbuddy-backup";
|
|
37516
|
+
BLOCK_NOTE = [
|
|
37517
|
+
"# Managed by Supbuddy. This machine is not loading /etc/resolver, so the names",
|
|
37518
|
+
"# below are resolved here instead. Edits are overwritten; the block is removed",
|
|
37519
|
+
"# automatically once /etc/resolver works again (see `supbuddy doctor`)."
|
|
37520
|
+
];
|
|
37521
|
+
}
|
|
37522
|
+
});
|
|
37523
|
+
|
|
37524
|
+
// ../../packages/core/dns-platform.ts
|
|
37448
37525
|
import fs13 from "fs/promises";
|
|
37449
37526
|
import path16 from "path";
|
|
37450
|
-
|
|
37527
|
+
function getManagedDomainSuffixes() {
|
|
37528
|
+
const store = useStore2.getState();
|
|
37529
|
+
const suffixes = /* @__PURE__ */ new Set();
|
|
37530
|
+
for (const mapping of store.mappings) {
|
|
37531
|
+
if (!isMappingServeable(mapping, store.getProject)) continue;
|
|
37532
|
+
const parts = mapping.domain.split(".").filter(Boolean);
|
|
37533
|
+
if (parts.length < 2) continue;
|
|
37534
|
+
const suffix = parts.length >= 3 ? parts.slice(1).join(".") : parts.join(".");
|
|
37535
|
+
suffixes.add(suffix);
|
|
37536
|
+
}
|
|
37537
|
+
return Array.from(suffixes);
|
|
37538
|
+
}
|
|
37539
|
+
async function buildMacOsCleanupCommand() {
|
|
37540
|
+
const resolverDir = "/etc/resolver";
|
|
37541
|
+
const toRemove = [];
|
|
37542
|
+
try {
|
|
37543
|
+
const files = await fs13.readdir(resolverDir);
|
|
37544
|
+
for (const file of files) {
|
|
37545
|
+
const filePath = path16.join(resolverDir, file);
|
|
37546
|
+
try {
|
|
37547
|
+
const content = await fs13.readFile(filePath, "utf-8");
|
|
37548
|
+
if (content.includes(SUPBUDDY_MARKER)) {
|
|
37549
|
+
toRemove.push(`rm "${filePath}"`);
|
|
37550
|
+
}
|
|
37551
|
+
} catch {
|
|
37552
|
+
}
|
|
37553
|
+
}
|
|
37554
|
+
} catch {
|
|
37555
|
+
}
|
|
37556
|
+
const { hasHostsFallbackBlock: hasHostsFallbackBlock2, buildHostsFallbackRemoveCommand: buildHostsFallbackRemoveCommand2 } = await Promise.resolve().then(() => (init_hosts_fallback(), hosts_fallback_exports));
|
|
37557
|
+
const removesHostsFallback = await hasHostsFallbackBlock2().catch(() => false);
|
|
37558
|
+
if (removesHostsFallback) toRemove.push(buildHostsFallbackRemoveCommand2());
|
|
37559
|
+
if (toRemove.length === 0) return null;
|
|
37560
|
+
return { command: toRemove.join(" && "), removesHostsFallback };
|
|
37561
|
+
}
|
|
37562
|
+
function buildLinuxCleanupCommand() {
|
|
37563
|
+
const configPath = "/etc/systemd/resolved.conf.d/supbuddy.conf";
|
|
37564
|
+
return `rm -f "${configPath}" && systemctl restart systemd-resolved 2>/dev/null || true`;
|
|
37565
|
+
}
|
|
37566
|
+
function buildWindowsCleanupCommand() {
|
|
37567
|
+
return `for /f "tokens=*" %k in ('reg query "HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DnsPolicyConfig" /s ^| findstr "Supbuddy-"') do reg delete "%k" /f 2>nul`;
|
|
37568
|
+
}
|
|
37569
|
+
async function buildPlatformDnsCleanupCommand() {
|
|
37570
|
+
const platform = process.platform;
|
|
37571
|
+
let command = null;
|
|
37572
|
+
let removesHostsFallback = false;
|
|
37573
|
+
switch (platform) {
|
|
37574
|
+
case "darwin": {
|
|
37575
|
+
const mac = await buildMacOsCleanupCommand();
|
|
37576
|
+
command = mac?.command ?? null;
|
|
37577
|
+
removesHostsFallback = mac?.removesHostsFallback ?? false;
|
|
37578
|
+
break;
|
|
37579
|
+
}
|
|
37580
|
+
case "linux":
|
|
37581
|
+
command = buildLinuxCleanupCommand();
|
|
37582
|
+
break;
|
|
37583
|
+
case "win32":
|
|
37584
|
+
command = buildWindowsCleanupCommand();
|
|
37585
|
+
break;
|
|
37586
|
+
default:
|
|
37587
|
+
return null;
|
|
37588
|
+
}
|
|
37589
|
+
if (!command) return null;
|
|
37590
|
+
return {
|
|
37591
|
+
command,
|
|
37592
|
+
prompt: removesHostsFallback ? "Supbuddy needs administrator access to remove DNS configuration and its /etc/hosts fallback entries." : "Supbuddy needs administrator access to remove DNS configuration.",
|
|
37593
|
+
removesHostsFallback
|
|
37594
|
+
};
|
|
37595
|
+
}
|
|
37596
|
+
async function auditResolverState() {
|
|
37597
|
+
const expected = getManagedDomainSuffixes();
|
|
37598
|
+
const platform = process.platform;
|
|
37599
|
+
if (platform === "darwin") {
|
|
37600
|
+
return auditMacOsResolver(expected);
|
|
37601
|
+
}
|
|
37602
|
+
if (platform === "linux") {
|
|
37603
|
+
return auditLinuxResolver(expected);
|
|
37604
|
+
}
|
|
37605
|
+
return { in_sync: null, missing: [], extra: [], expected };
|
|
37606
|
+
}
|
|
37607
|
+
async function auditMacOsResolver(expected) {
|
|
37608
|
+
const resolverDir = "/etc/resolver";
|
|
37609
|
+
let entries;
|
|
37610
|
+
try {
|
|
37611
|
+
entries = await fs13.readdir(resolverDir);
|
|
37612
|
+
} catch (err) {
|
|
37613
|
+
if (err.code === "ENOENT") {
|
|
37614
|
+
return {
|
|
37615
|
+
in_sync: expected.length === 0,
|
|
37616
|
+
missing: expected,
|
|
37617
|
+
extra: [],
|
|
37618
|
+
expected
|
|
37619
|
+
};
|
|
37620
|
+
}
|
|
37621
|
+
return { in_sync: null, missing: [], extra: [], expected, error: err.message };
|
|
37622
|
+
}
|
|
37623
|
+
const present = [];
|
|
37624
|
+
for (const file of entries) {
|
|
37625
|
+
try {
|
|
37626
|
+
const content = await fs13.readFile(path16.join(resolverDir, file), "utf-8");
|
|
37627
|
+
if (content.includes(SUPBUDDY_MARKER)) present.push(file);
|
|
37628
|
+
} catch {
|
|
37629
|
+
}
|
|
37630
|
+
}
|
|
37631
|
+
const expectedSet = new Set(expected);
|
|
37632
|
+
const presentSet = new Set(present);
|
|
37633
|
+
const missing = expected.filter((s) => !presentSet.has(s));
|
|
37634
|
+
const extra = present.filter((s) => !expectedSet.has(s));
|
|
37635
|
+
return { in_sync: missing.length === 0 && extra.length === 0, missing, extra, expected };
|
|
37636
|
+
}
|
|
37637
|
+
async function auditLinuxResolver(expected) {
|
|
37638
|
+
const configPath = "/etc/systemd/resolved.conf.d/supbuddy.conf";
|
|
37639
|
+
let content;
|
|
37640
|
+
try {
|
|
37641
|
+
content = await fs13.readFile(configPath, "utf-8");
|
|
37642
|
+
} catch (err) {
|
|
37643
|
+
if (err.code === "ENOENT") {
|
|
37644
|
+
return {
|
|
37645
|
+
in_sync: expected.length === 0,
|
|
37646
|
+
missing: expected,
|
|
37647
|
+
extra: [],
|
|
37648
|
+
expected
|
|
37649
|
+
};
|
|
37650
|
+
}
|
|
37651
|
+
return { in_sync: null, missing: [], extra: [], expected, error: err.message };
|
|
37652
|
+
}
|
|
37653
|
+
if (!content.includes(SUPBUDDY_MARKER)) {
|
|
37654
|
+
return { in_sync: false, missing: expected, extra: [], expected };
|
|
37655
|
+
}
|
|
37656
|
+
const match = content.match(/^Domains=(.*)$/m);
|
|
37657
|
+
const present = match ? match[1].split(/\s+/).map((d) => d.replace(/^~/, "").trim()).filter(Boolean) : [];
|
|
37658
|
+
const expectedSet = new Set(expected);
|
|
37659
|
+
const presentSet = new Set(present);
|
|
37660
|
+
const missing = expected.filter((s) => !presentSet.has(s));
|
|
37661
|
+
const extra = present.filter((s) => !expectedSet.has(s));
|
|
37662
|
+
return { in_sync: missing.length === 0 && extra.length === 0, missing, extra, expected };
|
|
37663
|
+
}
|
|
37664
|
+
var SUPBUDDY_MARKER;
|
|
37665
|
+
var init_dns_platform = __esm({
|
|
37666
|
+
"../../packages/core/dns-platform.ts"() {
|
|
37667
|
+
"use strict";
|
|
37668
|
+
init_store();
|
|
37669
|
+
init_mapping_scope();
|
|
37670
|
+
SUPBUDDY_MARKER = "# Managed by Supbuddy";
|
|
37671
|
+
}
|
|
37672
|
+
});
|
|
37673
|
+
|
|
37674
|
+
// ../../packages/core/caddyfile-generator.ts
|
|
37675
|
+
import nodeProcess7 from "process";
|
|
37676
|
+
import fs14 from "fs/promises";
|
|
37677
|
+
import path17 from "path";
|
|
37678
|
+
import os10 from "os";
|
|
37451
37679
|
async function getCaddyfileDir() {
|
|
37452
37680
|
const platform = process.platform;
|
|
37453
37681
|
let userDataPath;
|
|
37454
37682
|
if (platform === "darwin") {
|
|
37455
|
-
userDataPath =
|
|
37683
|
+
userDataPath = path17.join(os10.homedir(), "Library", "Application Support", "Supbuddy");
|
|
37456
37684
|
} else if (platform === "win32") {
|
|
37457
|
-
userDataPath =
|
|
37458
|
-
nodeProcess7.env.APPDATA ||
|
|
37685
|
+
userDataPath = path17.join(
|
|
37686
|
+
nodeProcess7.env.APPDATA || path17.join(os10.homedir(), "AppData", "Roaming"),
|
|
37459
37687
|
"Supbuddy"
|
|
37460
37688
|
);
|
|
37461
37689
|
} else {
|
|
37462
|
-
userDataPath =
|
|
37463
|
-
nodeProcess7.env.XDG_CONFIG_HOME ||
|
|
37690
|
+
userDataPath = path17.join(
|
|
37691
|
+
nodeProcess7.env.XDG_CONFIG_HOME || path17.join(os10.homedir(), ".config"),
|
|
37464
37692
|
"Supbuddy"
|
|
37465
37693
|
);
|
|
37466
37694
|
}
|
|
37467
|
-
await
|
|
37695
|
+
await fs14.mkdir(userDataPath, { recursive: true });
|
|
37468
37696
|
return userDataPath;
|
|
37469
37697
|
}
|
|
37470
37698
|
async function writeFileAtomic(filePath, content) {
|
|
37471
37699
|
const tmpPath = `${filePath}.${process.pid}.${tmpWriteCounter++}.tmp`;
|
|
37472
37700
|
try {
|
|
37473
|
-
await
|
|
37474
|
-
await
|
|
37701
|
+
await fs14.writeFile(tmpPath, content, "utf-8");
|
|
37702
|
+
await fs14.rename(tmpPath, filePath);
|
|
37475
37703
|
} catch (err) {
|
|
37476
|
-
await
|
|
37704
|
+
await fs14.rm(tmpPath, { force: true }).catch(() => {
|
|
37477
37705
|
});
|
|
37478
37706
|
throw err;
|
|
37479
37707
|
}
|
|
37480
37708
|
}
|
|
37481
37709
|
async function generateCaddyfile(mappings, settings, options = {}, validate) {
|
|
37482
37710
|
const caddyfileDir = await getCaddyfileDir();
|
|
37483
|
-
const caddyfilePath =
|
|
37711
|
+
const caddyfilePath = path17.join(caddyfileDir, "Caddyfile");
|
|
37484
37712
|
const caddyfileContent = buildCaddyfileContent(mappings, settings, options);
|
|
37485
37713
|
if (validate) {
|
|
37486
37714
|
const res = await validate(caddyfileContent);
|
|
@@ -37621,8 +37849,8 @@ function buildCaddyfileContent(mappings, settings, options = {}) {
|
|
|
37621
37849
|
}
|
|
37622
37850
|
async function getCaddyDataDir2() {
|
|
37623
37851
|
const caddyfileDir = await getCaddyfileDir();
|
|
37624
|
-
const dataDir =
|
|
37625
|
-
await
|
|
37852
|
+
const dataDir = path17.join(caddyfileDir, "caddy-data");
|
|
37853
|
+
await fs14.mkdir(dataDir, { recursive: true });
|
|
37626
37854
|
return dataDir;
|
|
37627
37855
|
}
|
|
37628
37856
|
var tmpWriteCounter;
|
|
@@ -37634,7 +37862,7 @@ var init_caddyfile_generator = __esm({
|
|
|
37634
37862
|
});
|
|
37635
37863
|
|
|
37636
37864
|
// ../../packages/core/mcp/env-utils.ts
|
|
37637
|
-
import
|
|
37865
|
+
import fs15 from "fs/promises";
|
|
37638
37866
|
function stripInlineComment(unquoted) {
|
|
37639
37867
|
for (let i = 0; i < unquoted.length; i++) {
|
|
37640
37868
|
if (unquoted[i] !== "#") continue;
|
|
@@ -37664,7 +37892,7 @@ function parseDotenv(content) {
|
|
|
37664
37892
|
}
|
|
37665
37893
|
async function readEnvFileAsDict(absolutePath) {
|
|
37666
37894
|
try {
|
|
37667
|
-
const content = await
|
|
37895
|
+
const content = await fs15.readFile(absolutePath, "utf-8");
|
|
37668
37896
|
return parseDotenv(content);
|
|
37669
37897
|
} catch (e) {
|
|
37670
37898
|
if (e.code === "ENOENT") return {};
|
|
@@ -37678,9 +37906,9 @@ var init_env_utils = __esm({
|
|
|
37678
37906
|
});
|
|
37679
37907
|
|
|
37680
37908
|
// ../../packages/core/project-env.ts
|
|
37681
|
-
import
|
|
37909
|
+
import path18 from "path";
|
|
37682
37910
|
async function resolveProjectEnv(projectPath) {
|
|
37683
|
-
const all = await readEnvFileAsDict(
|
|
37911
|
+
const all = await readEnvFileAsDict(path18.join(projectPath, ".env.local"));
|
|
37684
37912
|
const out = {};
|
|
37685
37913
|
for (const [k, v] of Object.entries(all)) {
|
|
37686
37914
|
if (k.startsWith("SUPABASE_")) out[k] = v;
|
|
@@ -37694,52 +37922,6 @@ var init_project_env = __esm({
|
|
|
37694
37922
|
}
|
|
37695
37923
|
});
|
|
37696
37924
|
|
|
37697
|
-
// ../../packages/core/supabase-ports.ts
|
|
37698
|
-
async function allocateSupabaseBlock(reservedBases, isFree) {
|
|
37699
|
-
for (let base = RANGE_START; base <= RANGE_END; base += BLOCK_SPAN) {
|
|
37700
|
-
if (reservedBases.has(base)) continue;
|
|
37701
|
-
let ok = true;
|
|
37702
|
-
for (let p = base; p < base + BLOCK_SPAN; p++) {
|
|
37703
|
-
if (!await isFree(p)) {
|
|
37704
|
-
ok = false;
|
|
37705
|
-
break;
|
|
37706
|
-
}
|
|
37707
|
-
}
|
|
37708
|
-
if (!ok) continue;
|
|
37709
|
-
return Object.fromEntries(SUPABASE_PORT_KEYS.map(({ key, stock }) => [key, base + (stock - STOCK_BASE)]));
|
|
37710
|
-
}
|
|
37711
|
-
return null;
|
|
37712
|
-
}
|
|
37713
|
-
function supabaseManagedProjectId(name, idShort) {
|
|
37714
|
-
const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
37715
|
-
const id = idShort.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
37716
|
-
return slug ? `sb-${slug}-${id}` : `sb-${id}`;
|
|
37717
|
-
}
|
|
37718
|
-
function remapVolumeName(volume, oldId, newId) {
|
|
37719
|
-
return volume.endsWith(oldId) ? volume.slice(0, volume.length - oldId.length) + newId : volume;
|
|
37720
|
-
}
|
|
37721
|
-
var SUPABASE_PORT_KEYS, STOCK_BASE, BLOCK_SPAN, RANGE_START, RANGE_END;
|
|
37722
|
-
var init_supabase_ports = __esm({
|
|
37723
|
-
"../../packages/core/supabase-ports.ts"() {
|
|
37724
|
-
"use strict";
|
|
37725
|
-
SUPABASE_PORT_KEYS = [
|
|
37726
|
-
{ key: "db.shadow_port", stock: 54320 },
|
|
37727
|
-
{ key: "api.port", stock: 54321 },
|
|
37728
|
-
{ key: "db.port", stock: 54322 },
|
|
37729
|
-
{ key: "studio.port", stock: 54323 },
|
|
37730
|
-
{ key: "inbucket.port", stock: 54324 },
|
|
37731
|
-
{ key: "inbucket.smtp_port", stock: 54325 },
|
|
37732
|
-
{ key: "inbucket.pop3_port", stock: 54326 },
|
|
37733
|
-
{ key: "analytics.port", stock: 54327 },
|
|
37734
|
-
{ key: "db.pooler.port", stock: 54329 }
|
|
37735
|
-
];
|
|
37736
|
-
STOCK_BASE = 54320;
|
|
37737
|
-
BLOCK_SPAN = 10;
|
|
37738
|
-
RANGE_START = 55e3;
|
|
37739
|
-
RANGE_END = 59990;
|
|
37740
|
-
}
|
|
37741
|
-
});
|
|
37742
|
-
|
|
37743
37925
|
// ../../packages/core/supabase-config-write.ts
|
|
37744
37926
|
function setSectionField(toml, dotted, value) {
|
|
37745
37927
|
const idx = dotted.lastIndexOf(".");
|
|
@@ -37770,24 +37952,27 @@ function setSectionField(toml, dotted, value) {
|
|
|
37770
37952
|
}
|
|
37771
37953
|
function applyManagedConfig(toml, ports, projectId) {
|
|
37772
37954
|
let out = toml;
|
|
37955
|
+
const mailSection = detectMailSection(toml);
|
|
37773
37956
|
const originals = { projectId: "", ports: {} };
|
|
37774
37957
|
const pid = setSectionField(out, "project_id", `"${projectId}"`);
|
|
37775
37958
|
out = pid.toml;
|
|
37776
37959
|
originals.projectId = pid.original ?? "";
|
|
37777
37960
|
for (const { key } of SUPABASE_PORT_KEYS) {
|
|
37778
37961
|
if (ports[key] == null) continue;
|
|
37779
|
-
const
|
|
37962
|
+
const dotted = mailKeyForSection(key, mailSection);
|
|
37963
|
+
const r = setSectionField(out, dotted, String(ports[key]));
|
|
37780
37964
|
out = r.toml;
|
|
37781
37965
|
if (r.original != null) originals.ports[key] = Number(r.original);
|
|
37782
|
-
else console.warn(`[supabase-config] managed port key "${
|
|
37966
|
+
else console.warn(`[supabase-config] managed port key "${dotted}" not found in config.toml \u2014 not rewritten (section likely omitted; the stack relies on the Supabase default for it and may collide with another thin stack)`);
|
|
37783
37967
|
}
|
|
37784
37968
|
return { toml: out, originals };
|
|
37785
37969
|
}
|
|
37786
37970
|
function restoreManagedConfig(toml, originals) {
|
|
37787
37971
|
let out = toml;
|
|
37972
|
+
const mailSection = detectMailSection(toml);
|
|
37788
37973
|
out = setSectionField(out, "project_id", `"${originals.projectId}"`).toml;
|
|
37789
37974
|
for (const [key, val] of Object.entries(originals.ports)) {
|
|
37790
|
-
out = setSectionField(out, key, String(val)).toml;
|
|
37975
|
+
out = setSectionField(out, mailKeyForSection(key, mailSection), String(val)).toml;
|
|
37791
37976
|
}
|
|
37792
37977
|
return out;
|
|
37793
37978
|
}
|
|
@@ -37874,6 +38059,7 @@ var init_supabase_progress = __esm({
|
|
|
37874
38059
|
// ../../packages/core/supabase-manager.ts
|
|
37875
38060
|
var supabase_manager_exports = {};
|
|
37876
38061
|
__export(supabase_manager_exports, {
|
|
38062
|
+
SupabaseStartRefusedError: () => SupabaseStartRefusedError,
|
|
37877
38063
|
THIN_SUPABASE_SOFT_CAP: () => THIN_SUPABASE_SOFT_CAP,
|
|
37878
38064
|
checkSupabasePortConflicts: () => checkSupabasePortConflicts,
|
|
37879
38065
|
countThinSupabaseStacks: () => countThinSupabaseStacks,
|
|
@@ -37885,8 +38071,10 @@ __export(supabase_manager_exports, {
|
|
|
37885
38071
|
isPortFree: () => isPortFree,
|
|
37886
38072
|
isSupabaseCLIInstalled: () => isSupabaseCLIInstalled,
|
|
37887
38073
|
isSupabaseInitialized: () => isSupabaseInitialized,
|
|
38074
|
+
isSupabaseStopInfo: () => isSupabaseStopInfo,
|
|
37888
38075
|
parseConfigPort: () => parseConfigPort,
|
|
37889
38076
|
parseDindSupabaseStackStatus: () => parseDindSupabaseStackStatus,
|
|
38077
|
+
parseOwnStackPublishedPorts: () => parseOwnStackPublishedPorts,
|
|
37890
38078
|
readSupabasePorts: () => readSupabasePorts,
|
|
37891
38079
|
removeSupabaseVolumes: () => removeSupabaseVolumes,
|
|
37892
38080
|
renameThinSupabaseStack: () => renameThinSupabaseStack,
|
|
@@ -37946,10 +38134,10 @@ async function isDockerRunning() {
|
|
|
37946
38134
|
}
|
|
37947
38135
|
async function isSupabaseInitialized(projectPath) {
|
|
37948
38136
|
try {
|
|
37949
|
-
const
|
|
37950
|
-
const
|
|
37951
|
-
const supabasePath =
|
|
37952
|
-
await
|
|
38137
|
+
const fs34 = await import("fs/promises");
|
|
38138
|
+
const path40 = await import("path");
|
|
38139
|
+
const supabasePath = path40.join(projectPath, "supabase");
|
|
38140
|
+
await fs34.access(supabasePath);
|
|
37953
38141
|
return true;
|
|
37954
38142
|
} catch {
|
|
37955
38143
|
return false;
|
|
@@ -37971,9 +38159,9 @@ function parseFieldFromBlock(block, field, fallback) {
|
|
|
37971
38159
|
}
|
|
37972
38160
|
async function parseConfigPort(projectPath, section, fallback, field = "port") {
|
|
37973
38161
|
try {
|
|
37974
|
-
const
|
|
37975
|
-
const
|
|
37976
|
-
const content = await
|
|
38162
|
+
const fs34 = await import("fs/promises");
|
|
38163
|
+
const path40 = await import("path");
|
|
38164
|
+
const content = await fs34.readFile(path40.join(projectPath, "supabase", "config.toml"), "utf-8");
|
|
37977
38165
|
const block = extractSectionBlock(content, section);
|
|
37978
38166
|
if (!block) return fallback;
|
|
37979
38167
|
return parseFieldFromBlock(block, field, fallback);
|
|
@@ -37982,30 +38170,43 @@ async function parseConfigPort(projectPath, section, fallback, field = "port") {
|
|
|
37982
38170
|
}
|
|
37983
38171
|
}
|
|
37984
38172
|
async function readSupabasePorts(projectPath) {
|
|
37985
|
-
const
|
|
37986
|
-
const
|
|
38173
|
+
const fs34 = await import("fs/promises");
|
|
38174
|
+
const path40 = await import("path");
|
|
37987
38175
|
let content = "";
|
|
37988
38176
|
try {
|
|
37989
|
-
content = await
|
|
38177
|
+
content = await fs34.readFile(path40.join(projectPath, "supabase", "config.toml"), "utf-8");
|
|
37990
38178
|
} catch {
|
|
37991
|
-
return {
|
|
37992
|
-
|
|
38179
|
+
return {
|
|
38180
|
+
db: 54322,
|
|
38181
|
+
api: 54321,
|
|
38182
|
+
studio: 54323,
|
|
38183
|
+
inbucket: MAIL_STOCK_PORTS.port,
|
|
38184
|
+
shadow: 54320,
|
|
38185
|
+
pooler: 54329,
|
|
38186
|
+
smtp: MAIL_STOCK_PORTS.smtp_port,
|
|
38187
|
+
pop3: MAIL_STOCK_PORTS.pop3_port,
|
|
38188
|
+
analytics: 54327,
|
|
38189
|
+
mailSection: null
|
|
38190
|
+
};
|
|
38191
|
+
}
|
|
38192
|
+
const mailSection = detectMailSection(content);
|
|
37993
38193
|
const dbBlock = extractSectionBlock(content, "db") ?? "";
|
|
37994
38194
|
const apiBlock = extractSectionBlock(content, "api") ?? "";
|
|
37995
38195
|
const studioBlock = extractSectionBlock(content, "studio") ?? "";
|
|
37996
|
-
const
|
|
38196
|
+
const mailBlock = (mailSection && extractSectionBlock(content, mailSection)) ?? "";
|
|
37997
38197
|
const poolerBlock = extractSectionBlock(content, "db.pooler") ?? "";
|
|
37998
38198
|
const analyticsBlock = extractSectionBlock(content, "analytics") ?? "";
|
|
37999
38199
|
return {
|
|
38000
38200
|
db: parseFieldFromBlock(dbBlock, "port", 54322),
|
|
38001
38201
|
api: parseFieldFromBlock(apiBlock, "port", 54321),
|
|
38002
38202
|
studio: parseFieldFromBlock(studioBlock, "port", 54323),
|
|
38003
|
-
inbucket: parseFieldFromBlock(
|
|
38203
|
+
inbucket: parseFieldFromBlock(mailBlock, "port", MAIL_STOCK_PORTS.port),
|
|
38004
38204
|
shadow: parseFieldFromBlock(dbBlock, "shadow_port", 54320),
|
|
38005
38205
|
pooler: parseFieldFromBlock(poolerBlock, "port", 54329),
|
|
38006
|
-
smtp: parseFieldFromBlock(
|
|
38007
|
-
pop3: parseFieldFromBlock(
|
|
38008
|
-
analytics: parseFieldFromBlock(analyticsBlock, "port", 54327)
|
|
38206
|
+
smtp: parseFieldFromBlock(mailBlock, "smtp_port", MAIL_STOCK_PORTS.smtp_port),
|
|
38207
|
+
pop3: parseFieldFromBlock(mailBlock, "pop3_port", MAIL_STOCK_PORTS.pop3_port),
|
|
38208
|
+
analytics: parseFieldFromBlock(analyticsBlock, "port", 54327),
|
|
38209
|
+
mailSection
|
|
38009
38210
|
};
|
|
38010
38211
|
}
|
|
38011
38212
|
async function isPortInUse(port) {
|
|
@@ -38024,23 +38225,27 @@ async function isPortInUse(port) {
|
|
|
38024
38225
|
async function isPortFree(port) {
|
|
38025
38226
|
return !(await isPortInUse(port)).inUse;
|
|
38026
38227
|
}
|
|
38027
|
-
async function checkSupabasePortConflicts(projectPath) {
|
|
38228
|
+
async function checkSupabasePortConflicts(projectPath, opts = {}) {
|
|
38229
|
+
const ports = await readSupabasePorts(projectPath);
|
|
38028
38230
|
const services = [
|
|
38029
|
-
{ service: "PostgreSQL",
|
|
38030
|
-
{ service: "API",
|
|
38031
|
-
{ service: "Studio",
|
|
38032
|
-
{ service:
|
|
38231
|
+
{ service: "PostgreSQL", port: ports.db },
|
|
38232
|
+
{ service: "API", port: ports.api },
|
|
38233
|
+
{ service: "Studio", port: ports.studio },
|
|
38234
|
+
{ service: mailServiceLabel(ports.mailSection), port: ports.inbucket }
|
|
38033
38235
|
];
|
|
38034
38236
|
const conflicts = [];
|
|
38035
38237
|
for (const s of services) {
|
|
38036
|
-
|
|
38037
|
-
const status = await isPortInUse(port);
|
|
38238
|
+
if (opts.ignorePorts?.has(s.port)) continue;
|
|
38239
|
+
const status = await isPortInUse(s.port);
|
|
38038
38240
|
if (status.inUse) {
|
|
38039
|
-
conflicts.push({ service: s.service, port, usedBy: status.usedBy ?? "unknown process" });
|
|
38241
|
+
conflicts.push({ service: s.service, port: s.port, usedBy: status.usedBy ?? "unknown process" });
|
|
38040
38242
|
}
|
|
38041
38243
|
}
|
|
38042
38244
|
return conflicts;
|
|
38043
38245
|
}
|
|
38246
|
+
function mailServiceLabel(section) {
|
|
38247
|
+
return section === "local_smtp" ? "Mailpit" : "Inbucket";
|
|
38248
|
+
}
|
|
38044
38249
|
async function startSupabase(projectId, projectPath, io2) {
|
|
38045
38250
|
console.log(`[SupabaseManager] Starting Supabase for project: ${projectId}`);
|
|
38046
38251
|
if (activeOperations.has(projectId)) {
|
|
@@ -38225,10 +38430,12 @@ async function stopSupabase(projectId, projectPath, io2) {
|
|
|
38225
38430
|
child.stderr?.on("data", (data) => {
|
|
38226
38431
|
const stderrOutput = data.toString();
|
|
38227
38432
|
output += stderrOutput;
|
|
38228
|
-
if (
|
|
38229
|
-
console.
|
|
38230
|
-
} else {
|
|
38433
|
+
if (isSupabaseStopInfo(stderrOutput)) {
|
|
38434
|
+
console.log(`[SupabaseManager] ${stderrOutput.trim()}`);
|
|
38435
|
+
} else if (isWarning(stderrOutput)) {
|
|
38231
38436
|
console.log(`[SupabaseManager] Warning: ${stderrOutput.trim()}`);
|
|
38437
|
+
} else {
|
|
38438
|
+
console.error(`[SupabaseManager] Error: ${stderrOutput}`);
|
|
38232
38439
|
}
|
|
38233
38440
|
});
|
|
38234
38441
|
child.on("close", (code) => {
|
|
@@ -38267,8 +38474,43 @@ async function stopSupabase(projectId, projectPath, io2) {
|
|
|
38267
38474
|
});
|
|
38268
38475
|
});
|
|
38269
38476
|
}
|
|
38477
|
+
function parseOwnStackPublishedPorts(psOutput, composeProjectId) {
|
|
38478
|
+
const ports = /* @__PURE__ */ new Set();
|
|
38479
|
+
if (!composeProjectId) return ports;
|
|
38480
|
+
for (const line of psOutput.split("\n")) {
|
|
38481
|
+
const [name, portsCol = ""] = line.split(" ");
|
|
38482
|
+
if (!name?.trim().endsWith(`_${composeProjectId}`)) continue;
|
|
38483
|
+
for (const m of portsCol.matchAll(/(?:0\.0\.0\.0|127\.0\.0\.1|\[::\]|\[::1\]):(\d+)->/g)) {
|
|
38484
|
+
ports.add(parseInt(m[1], 10));
|
|
38485
|
+
}
|
|
38486
|
+
}
|
|
38487
|
+
return ports;
|
|
38488
|
+
}
|
|
38489
|
+
async function readConfigProjectId(projectPath) {
|
|
38490
|
+
try {
|
|
38491
|
+
const fs34 = await import("fs/promises");
|
|
38492
|
+
const path40 = await import("path");
|
|
38493
|
+
const content = await fs34.readFile(path40.join(projectPath, "supabase", "config.toml"), "utf-8");
|
|
38494
|
+
const m = content.match(/^\s*project_id\s*=\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m);
|
|
38495
|
+
return m ? m[1].trim() : null;
|
|
38496
|
+
} catch {
|
|
38497
|
+
return null;
|
|
38498
|
+
}
|
|
38499
|
+
}
|
|
38500
|
+
async function ownStackPublishedPorts(projectPath) {
|
|
38501
|
+
const composeProjectId = await readConfigProjectId(projectPath);
|
|
38502
|
+
if (!composeProjectId) return /* @__PURE__ */ new Set();
|
|
38503
|
+
const { stdout } = await execAsync4(
|
|
38504
|
+
`docker ps --filter name=supabase_ --format "{{.Names}} {{.Ports}}"`,
|
|
38505
|
+
{ timeout: 1e4 }
|
|
38506
|
+
);
|
|
38507
|
+
return parseOwnStackPublishedPorts(stdout, composeProjectId);
|
|
38508
|
+
}
|
|
38270
38509
|
async function restartSupabase(projectId, projectPath, io2) {
|
|
38271
38510
|
console.log(`[SupabaseManager] Restarting Supabase for project: ${projectId}`);
|
|
38511
|
+
const ownPorts = await ownStackPublishedPorts(projectPath).catch(() => /* @__PURE__ */ new Set());
|
|
38512
|
+
const conflicts = await checkSupabasePortConflicts(projectPath, { ignorePorts: ownPorts });
|
|
38513
|
+
if (conflicts.length > 0) throw new SupabaseStartRefusedError(conflicts);
|
|
38272
38514
|
await stopSupabase(projectId, projectPath, io2);
|
|
38273
38515
|
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
38274
38516
|
await startSupabase(projectId, projectPath, io2);
|
|
@@ -38296,6 +38538,9 @@ function parseSupabaseError(output) {
|
|
|
38296
38538
|
}
|
|
38297
38539
|
return output.trim() || "Unknown error occurred";
|
|
38298
38540
|
}
|
|
38541
|
+
function isSupabaseStopInfo(output) {
|
|
38542
|
+
return /Local data are backed up to docker volume|Stopped supabase local development setup/i.test(output);
|
|
38543
|
+
}
|
|
38299
38544
|
function isWarning(output) {
|
|
38300
38545
|
const warningPatterns = [
|
|
38301
38546
|
"A new version of Supabase CLI is available",
|
|
@@ -38324,12 +38569,12 @@ function takenSupabaseProjectIds(projects, excludeId) {
|
|
|
38324
38569
|
}
|
|
38325
38570
|
async function resolveSupaDir(project) {
|
|
38326
38571
|
if (!project.path) return null;
|
|
38327
|
-
const
|
|
38328
|
-
const
|
|
38329
|
-
const supaDir = project.supabasePath ?
|
|
38330
|
-
const configPath =
|
|
38572
|
+
const path40 = await import("path");
|
|
38573
|
+
const fs34 = await import("fs/promises");
|
|
38574
|
+
const supaDir = project.supabasePath ? path40.join(project.path, project.supabasePath) : project.path;
|
|
38575
|
+
const configPath = path40.join(supaDir, "supabase", "config.toml");
|
|
38331
38576
|
try {
|
|
38332
|
-
await
|
|
38577
|
+
await fs34.access(configPath);
|
|
38333
38578
|
} catch {
|
|
38334
38579
|
return null;
|
|
38335
38580
|
}
|
|
@@ -38339,18 +38584,18 @@ async function enableThinSupabase(project, io2) {
|
|
|
38339
38584
|
const resolved = await resolveSupaDir(project);
|
|
38340
38585
|
if (!resolved) return;
|
|
38341
38586
|
const { supaDir, configPath } = resolved;
|
|
38342
|
-
const
|
|
38587
|
+
const fs34 = await import("fs/promises");
|
|
38343
38588
|
return runSerial(async () => {
|
|
38344
38589
|
const store = useStore2.getState();
|
|
38345
38590
|
const reserved = reservedSupabaseBases(store.projects, project.id);
|
|
38346
38591
|
const ports = project.supabaseManaged?.ports ?? await allocateSupabaseBlock(reserved, isPortFree);
|
|
38347
38592
|
if (ports == null) throw new Error("No free Supabase port block available");
|
|
38348
38593
|
const managedId = project.supabaseManaged?.projectId ?? supabaseManagedProjectId(project.name ?? "", project.id.slice(0, 8));
|
|
38349
|
-
const current = await
|
|
38594
|
+
const current = await fs34.readFile(configPath, "utf-8");
|
|
38350
38595
|
const { toml, originals } = applyManagedConfig(current, ports, managedId);
|
|
38351
38596
|
await stopSupabase(project.id, supaDir, io2).catch(() => {
|
|
38352
38597
|
});
|
|
38353
|
-
await
|
|
38598
|
+
await fs34.writeFile(configPath, toml);
|
|
38354
38599
|
store.updateProject(project.id, { supabaseManaged: { projectId: managedId, ports, originals } });
|
|
38355
38600
|
if (countThinSupabaseStacks(store.projects) >= THIN_SUPABASE_SOFT_CAP) {
|
|
38356
38601
|
io2?.emit("supabase:operation", {
|
|
@@ -38363,7 +38608,7 @@ async function enableThinSupabase(project, io2) {
|
|
|
38363
38608
|
try {
|
|
38364
38609
|
await startSupabase(project.id, supaDir, io2);
|
|
38365
38610
|
} catch (err) {
|
|
38366
|
-
await
|
|
38611
|
+
await fs34.writeFile(configPath, restoreManagedConfig(toml, originals)).catch(() => {
|
|
38367
38612
|
});
|
|
38368
38613
|
await stopSupabase(project.id, supaDir, io2).catch(() => {
|
|
38369
38614
|
});
|
|
@@ -38377,11 +38622,11 @@ async function disableThinSupabase(project, io2) {
|
|
|
38377
38622
|
const resolved = await resolveSupaDir(project);
|
|
38378
38623
|
if (!resolved) return;
|
|
38379
38624
|
const { supaDir, configPath } = resolved;
|
|
38380
|
-
const
|
|
38625
|
+
const fs34 = await import("fs/promises");
|
|
38381
38626
|
await stopSupabase(project.id, supaDir, io2).catch(() => {
|
|
38382
38627
|
});
|
|
38383
|
-
const current = await
|
|
38384
|
-
await
|
|
38628
|
+
const current = await fs34.readFile(configPath, "utf-8");
|
|
38629
|
+
await fs34.writeFile(configPath, restoreManagedConfig(current, project.supabaseManaged.originals));
|
|
38385
38630
|
}
|
|
38386
38631
|
async function renameThinSupabaseStack(project, io2) {
|
|
38387
38632
|
const projectId = project.id;
|
|
@@ -38403,20 +38648,20 @@ async function renameThinSupabaseStackLocked(projectId, io2) {
|
|
|
38403
38648
|
const newId = supabaseManagedProjectId(project.name ?? "", project.id.slice(0, 8));
|
|
38404
38649
|
if (newId === oldId) return;
|
|
38405
38650
|
console.log(`[thin:rename] project ${projectId}: ${oldId} \u2192 ${newId}`);
|
|
38406
|
-
const
|
|
38407
|
-
const current = await
|
|
38651
|
+
const fs34 = await import("fs/promises");
|
|
38652
|
+
const current = await fs34.readFile(configPath, "utf-8");
|
|
38408
38653
|
await stopSupabase(projectId, supaDir, io2).catch(() => {
|
|
38409
38654
|
});
|
|
38410
38655
|
try {
|
|
38411
38656
|
await migrateSupabaseVolumes(oldId, newId);
|
|
38412
|
-
await
|
|
38657
|
+
await fs34.writeFile(configPath, setManagedProjectId(current, newId));
|
|
38413
38658
|
useStore2.getState().updateProject(projectId, {
|
|
38414
38659
|
supabaseManaged: { ...managed, projectId: newId }
|
|
38415
38660
|
});
|
|
38416
38661
|
await runSerial(() => startSupabase(projectId, supaDir, io2));
|
|
38417
38662
|
} catch (err) {
|
|
38418
38663
|
console.error(`[thin:rename] failed; rolling back to ${oldId}:`, err);
|
|
38419
|
-
await
|
|
38664
|
+
await fs34.writeFile(configPath, current).catch(() => {
|
|
38420
38665
|
});
|
|
38421
38666
|
useStore2.getState().updateProject(projectId, {
|
|
38422
38667
|
supabaseManaged: { ...managed, projectId: oldId }
|
|
@@ -38456,7 +38701,7 @@ async function removeSupabaseVolumes(id) {
|
|
|
38456
38701
|
});
|
|
38457
38702
|
}
|
|
38458
38703
|
}
|
|
38459
|
-
var serialChain, THIN_SUPABASE_SOFT_CAP, execAsync4, activeOperations, renameLocks;
|
|
38704
|
+
var serialChain, THIN_SUPABASE_SOFT_CAP, execAsync4, activeOperations, SupabaseStartRefusedError, renameLocks;
|
|
38460
38705
|
var init_supabase_manager = __esm({
|
|
38461
38706
|
"../../packages/core/supabase-manager.ts"() {
|
|
38462
38707
|
"use strict";
|
|
@@ -38469,6 +38714,20 @@ var init_supabase_manager = __esm({
|
|
|
38469
38714
|
THIN_SUPABASE_SOFT_CAP = 6;
|
|
38470
38715
|
execAsync4 = promisify6(exec5);
|
|
38471
38716
|
activeOperations = /* @__PURE__ */ new Map();
|
|
38717
|
+
SupabaseStartRefusedError = class extends Error {
|
|
38718
|
+
constructor(conflicts) {
|
|
38719
|
+
super(
|
|
38720
|
+
`Refusing to restart Supabase \u2014 it could not be started again, so the running stack was left running:
|
|
38721
|
+
` + conflicts.map((c) => ` \u2022 ${c.service} needs port ${c.port} (in use by ${c.usedBy})`).join("\n") + `
|
|
38722
|
+
|
|
38723
|
+
Free that port (or stop the other project), then restart.`
|
|
38724
|
+
);
|
|
38725
|
+
this.conflicts = conflicts;
|
|
38726
|
+
this.name = "SupabaseStartRefusedError";
|
|
38727
|
+
}
|
|
38728
|
+
conflicts;
|
|
38729
|
+
stackUntouched = true;
|
|
38730
|
+
};
|
|
38472
38731
|
renameLocks = /* @__PURE__ */ new Map();
|
|
38473
38732
|
}
|
|
38474
38733
|
});
|
|
@@ -38494,14 +38753,14 @@ function buildL4AppConfig() {
|
|
|
38494
38753
|
}
|
|
38495
38754
|
return { servers };
|
|
38496
38755
|
}
|
|
38497
|
-
function adminRequest(method,
|
|
38756
|
+
function adminRequest(method, path40, body) {
|
|
38498
38757
|
return new Promise((resolve, reject) => {
|
|
38499
38758
|
const payload = body === void 0 ? void 0 : JSON.stringify(body);
|
|
38500
38759
|
const req = http.request(
|
|
38501
38760
|
{
|
|
38502
38761
|
host: ADMIN_HOST,
|
|
38503
38762
|
port: ADMIN_PORT,
|
|
38504
|
-
path:
|
|
38763
|
+
path: path40,
|
|
38505
38764
|
method,
|
|
38506
38765
|
headers: payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {},
|
|
38507
38766
|
timeout: 5e3
|
|
@@ -38711,8 +38970,8 @@ var init_caddy_supervisor = __esm({
|
|
|
38711
38970
|
import { spawn as spawn5, execFile as execFile4 } from "child_process";
|
|
38712
38971
|
import { promisify as promisify7 } from "util";
|
|
38713
38972
|
import { randomUUID } from "crypto";
|
|
38714
|
-
import
|
|
38715
|
-
import
|
|
38973
|
+
import path19 from "path";
|
|
38974
|
+
import fs16 from "fs/promises";
|
|
38716
38975
|
import nodeProcess9 from "process";
|
|
38717
38976
|
function getCaddyBinaryPath() {
|
|
38718
38977
|
const platform = process.platform;
|
|
@@ -38727,11 +38986,11 @@ function getCaddyBinaryPath() {
|
|
|
38727
38986
|
}
|
|
38728
38987
|
const bundledBinDir = nodeProcess9.env.SUPBUDDY_BIN_DIR;
|
|
38729
38988
|
if (bundledBinDir) {
|
|
38730
|
-
return
|
|
38989
|
+
return path19.join(bundledBinDir, binaryName);
|
|
38731
38990
|
}
|
|
38732
38991
|
const isDev = nodeProcess9.env.NODE_ENV !== "production";
|
|
38733
38992
|
if (isDev) {
|
|
38734
|
-
return
|
|
38993
|
+
return path19.join(process.cwd(), "resources", "bin", binaryName);
|
|
38735
38994
|
}
|
|
38736
38995
|
const resourcesPath = process.resourcesPath;
|
|
38737
38996
|
if (!resourcesPath) {
|
|
@@ -38739,11 +38998,11 @@ function getCaddyBinaryPath() {
|
|
|
38739
38998
|
`cannot locate ${binaryName}: SUPBUDDY_BIN_DIR is not set and this runtime has no process.resourcesPath (that is Electron-only). The host must set SUPBUDDY_BIN_DIR to the directory holding the bundled binaries.`
|
|
38740
38999
|
);
|
|
38741
39000
|
}
|
|
38742
|
-
return
|
|
39001
|
+
return path19.join(resourcesPath, "bin", binaryName);
|
|
38743
39002
|
}
|
|
38744
39003
|
async function reapStaleCaddyProcesses() {
|
|
38745
39004
|
if (process.platform === "win32") return;
|
|
38746
|
-
const binaryName =
|
|
39005
|
+
const binaryName = path19.basename(getCaddyBinaryPath());
|
|
38747
39006
|
let survivors = await listCaddyPids(binaryName);
|
|
38748
39007
|
for (const pid of survivors) {
|
|
38749
39008
|
sendSignalIgnoringMissing(pid, "SIGTERM");
|
|
@@ -38836,7 +39095,7 @@ async function doStartCaddyServer() {
|
|
|
38836
39095
|
...nodeProcess9.env,
|
|
38837
39096
|
HOME: homeDir,
|
|
38838
39097
|
XDG_DATA_HOME: dataDir,
|
|
38839
|
-
XDG_CONFIG_HOME:
|
|
39098
|
+
XDG_CONFIG_HOME: path19.dirname(caddyfilePath)
|
|
38840
39099
|
};
|
|
38841
39100
|
caddyProcess = spawn5(binaryPath, ["run", "--config", caddyfilePath, "--adapter", "caddyfile"], {
|
|
38842
39101
|
env: caddyEnv,
|
|
@@ -39013,10 +39272,10 @@ function isCaddyErrorLine(line) {
|
|
|
39013
39272
|
async function validateCaddyfileContent(content) {
|
|
39014
39273
|
const binaryPath = getCaddyBinaryPath();
|
|
39015
39274
|
const dir = await getCaddyfileDir();
|
|
39016
|
-
const tmpPath =
|
|
39275
|
+
const tmpPath = path19.join(dir, `Caddyfile.validate.${process.pid}.${caddyValidateCounter++}.tmp`);
|
|
39017
39276
|
const execFileAsync5 = promisify7(execFile4);
|
|
39018
39277
|
try {
|
|
39019
|
-
await
|
|
39278
|
+
await fs16.writeFile(tmpPath, content, "utf-8");
|
|
39020
39279
|
await execFileAsync5(binaryPath, ["validate", "--config", tmpPath, "--adapter", "caddyfile"]);
|
|
39021
39280
|
return { ok: true };
|
|
39022
39281
|
} catch (err) {
|
|
@@ -39028,7 +39287,7 @@ async function validateCaddyfileContent(content) {
|
|
|
39028
39287
|
const msg2 = (e?.stderr || e?.message || String(err)).toString().trim();
|
|
39029
39288
|
return { ok: false, error: msg2.slice(0, 4e3) };
|
|
39030
39289
|
} finally {
|
|
39031
|
-
await
|
|
39290
|
+
await fs16.rm(tmpPath, { force: true }).catch(() => {
|
|
39032
39291
|
});
|
|
39033
39292
|
}
|
|
39034
39293
|
}
|
|
@@ -39279,7 +39538,7 @@ var init_caddy_docker_leftovers = __esm({
|
|
|
39279
39538
|
// ../../packages/core/port-forwarding.ts
|
|
39280
39539
|
import { exec as exec6 } from "child_process";
|
|
39281
39540
|
import { promisify as promisify9 } from "util";
|
|
39282
|
-
import
|
|
39541
|
+
import fs17 from "fs/promises";
|
|
39283
39542
|
async function probe443(timeoutMs = 1e3) {
|
|
39284
39543
|
return probePort(443, timeoutMs);
|
|
39285
39544
|
}
|
|
@@ -39314,7 +39573,7 @@ async function probeHost(host, port, timeoutMs) {
|
|
|
39314
39573
|
}
|
|
39315
39574
|
async function isPortForwardingEnabled() {
|
|
39316
39575
|
try {
|
|
39317
|
-
await
|
|
39576
|
+
await fs17.access("/etc/pf.anchors/virtual.localhost");
|
|
39318
39577
|
return true;
|
|
39319
39578
|
} catch {
|
|
39320
39579
|
return false;
|
|
@@ -39322,7 +39581,7 @@ async function isPortForwardingEnabled() {
|
|
|
39322
39581
|
}
|
|
39323
39582
|
async function hasPfConfAnchorReference() {
|
|
39324
39583
|
try {
|
|
39325
|
-
return (await
|
|
39584
|
+
return (await fs17.readFile("/etc/pf.conf", "utf-8")).includes("virtual.localhost");
|
|
39326
39585
|
} catch {
|
|
39327
39586
|
return false;
|
|
39328
39587
|
}
|
|
@@ -39350,17 +39609,17 @@ var init_port_forwarding = __esm({
|
|
|
39350
39609
|
});
|
|
39351
39610
|
|
|
39352
39611
|
// ../../packages/core/hosts-manager.ts
|
|
39353
|
-
import
|
|
39612
|
+
import fs18 from "fs/promises";
|
|
39354
39613
|
async function hasLegacyHostEntries() {
|
|
39355
39614
|
try {
|
|
39356
|
-
return (await
|
|
39615
|
+
return (await fs18.readFile(HOSTS_FILE_PATH, "utf-8")).includes(MARKER_START);
|
|
39357
39616
|
} catch {
|
|
39358
39617
|
return false;
|
|
39359
39618
|
}
|
|
39360
39619
|
}
|
|
39361
39620
|
async function cleanupLegacyHostEntries(elevate = defaultElevate2) {
|
|
39362
39621
|
try {
|
|
39363
|
-
const content = await
|
|
39622
|
+
const content = await fs18.readFile(HOSTS_FILE_PATH, "utf-8");
|
|
39364
39623
|
if (!content.includes(MARKER_START)) {
|
|
39365
39624
|
return false;
|
|
39366
39625
|
}
|
|
@@ -39383,7 +39642,7 @@ async function cleanupLegacyHostEntries(elevate = defaultElevate2) {
|
|
|
39383
39642
|
const newContent = cleaned.join("\n");
|
|
39384
39643
|
if (newContent === content) return false;
|
|
39385
39644
|
const tempFile = process.platform === "win32" ? "C:\\Windows\\Temp\\hosts" : "/tmp/hosts";
|
|
39386
|
-
await
|
|
39645
|
+
await fs18.writeFile(tempFile, newContent, "utf-8");
|
|
39387
39646
|
const command = process.platform === "win32" ? `copy /Y "${tempFile}" "${HOSTS_FILE_PATH}"` : `cp "${tempFile}" "${HOSTS_FILE_PATH}"`;
|
|
39388
39647
|
const result = await elevate({
|
|
39389
39648
|
command,
|
|
@@ -39449,6 +39708,7 @@ var init_system_integrations = __esm({
|
|
|
39449
39708
|
init_dns_platform();
|
|
39450
39709
|
init_lo_alias_manager();
|
|
39451
39710
|
init_hosts_manager();
|
|
39711
|
+
init_hosts_fallback();
|
|
39452
39712
|
THIN_ALIAS_RE = /^127\.0\.0\.\d{1,3}$/;
|
|
39453
39713
|
BASE_LOOPBACK = "127.0.0.1";
|
|
39454
39714
|
systemIntegrations = {
|
|
@@ -39467,6 +39727,7 @@ var init_system_integrations = __esm({
|
|
|
39467
39727
|
if (dns2) {
|
|
39468
39728
|
parts.push(dns2.command);
|
|
39469
39729
|
summary.push("the DNS resolver configuration");
|
|
39730
|
+
if (dns2.removesHostsFallback) summary.push("the Supbuddy /etc/hosts fallback entries");
|
|
39470
39731
|
}
|
|
39471
39732
|
const aliases = currentAliases(ctx);
|
|
39472
39733
|
if (aliases.length > 0) {
|
|
@@ -39511,6 +39772,11 @@ var init_system_integrations = __esm({
|
|
|
39511
39772
|
);
|
|
39512
39773
|
}
|
|
39513
39774
|
}
|
|
39775
|
+
if (dns2?.removesHostsFallback && await hasHostsFallbackBlock().catch(() => false)) {
|
|
39776
|
+
problems.push(
|
|
39777
|
+
"the Supbuddy block is still present in /etc/hosts (the elevated edit failed or was cancelled)"
|
|
39778
|
+
);
|
|
39779
|
+
}
|
|
39514
39780
|
if (aliases.length > 0) {
|
|
39515
39781
|
const left = currentAliases(ctx);
|
|
39516
39782
|
if (left.length > 0) problems.push(`loopback alias(es) still on lo0: ${left.join(", ")}`);
|
|
@@ -39542,7 +39808,7 @@ var init_system_integrations = __esm({
|
|
|
39542
39808
|
});
|
|
39543
39809
|
|
|
39544
39810
|
// ../../packages/core/system-doctor/wipe/steps/ca-and-trust.ts
|
|
39545
|
-
import
|
|
39811
|
+
import fs19 from "fs/promises";
|
|
39546
39812
|
async function trustedCaddyRoots(ctx) {
|
|
39547
39813
|
if (ctx.platform === "darwin") {
|
|
39548
39814
|
try {
|
|
@@ -39553,7 +39819,7 @@ async function trustedCaddyRoots(ctx) {
|
|
|
39553
39819
|
}
|
|
39554
39820
|
if (ctx.platform === "linux") {
|
|
39555
39821
|
try {
|
|
39556
|
-
await
|
|
39822
|
+
await fs19.access(LINUX_CA_PATH);
|
|
39557
39823
|
return 1;
|
|
39558
39824
|
} catch {
|
|
39559
39825
|
return 0;
|
|
@@ -39624,7 +39890,7 @@ var init_ca_and_trust = __esm({
|
|
|
39624
39890
|
|
|
39625
39891
|
// ../../packages/core/system-doctor/checks/orphan-mcp-secrets.ts
|
|
39626
39892
|
import nodeProcess10 from "process";
|
|
39627
|
-
import
|
|
39893
|
+
import path20 from "path";
|
|
39628
39894
|
function secretKey(clientId) {
|
|
39629
39895
|
return clientId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
39630
39896
|
}
|
|
@@ -39636,8 +39902,8 @@ function liveSecretKeys() {
|
|
|
39636
39902
|
return new Set(clients.filter((c) => !c.revoked).map((c) => secretKey(c.id)));
|
|
39637
39903
|
}
|
|
39638
39904
|
function isRemovableSecret(p, dir, live) {
|
|
39639
|
-
if (
|
|
39640
|
-
const m = SECRET_RE.exec(
|
|
39905
|
+
if (path20.dirname(p) !== dir) return false;
|
|
39906
|
+
const m = SECRET_RE.exec(path20.basename(p));
|
|
39641
39907
|
return m !== null && !live.has(m[1]);
|
|
39642
39908
|
}
|
|
39643
39909
|
var SECRET_RE;
|
|
@@ -39650,8 +39916,8 @@ var init_orphan_mcp_secrets = __esm({
|
|
|
39650
39916
|
});
|
|
39651
39917
|
|
|
39652
39918
|
// ../../packages/core/system-doctor/wipe/steps/revoked-mcp-secrets.ts
|
|
39653
|
-
import
|
|
39654
|
-
import
|
|
39919
|
+
import fs20 from "fs/promises";
|
|
39920
|
+
import path21 from "path";
|
|
39655
39921
|
var revokedMcpSecrets;
|
|
39656
39922
|
var init_revoked_mcp_secrets = __esm({
|
|
39657
39923
|
"../../packages/core/system-doctor/wipe/steps/revoked-mcp-secrets.ts"() {
|
|
@@ -39662,7 +39928,7 @@ var init_revoked_mcp_secrets = __esm({
|
|
|
39662
39928
|
tiers: ["deep", "full"],
|
|
39663
39929
|
destroysUserData: false,
|
|
39664
39930
|
async build(ctx) {
|
|
39665
|
-
const dir =
|
|
39931
|
+
const dir = path21.join(ctx.appSupportDir, "secrets");
|
|
39666
39932
|
let live;
|
|
39667
39933
|
try {
|
|
39668
39934
|
live = liveSecretKeys();
|
|
@@ -39671,21 +39937,21 @@ var init_revoked_mcp_secrets = __esm({
|
|
|
39671
39937
|
}
|
|
39672
39938
|
let entries;
|
|
39673
39939
|
try {
|
|
39674
|
-
entries = await
|
|
39940
|
+
entries = await fs20.readdir(dir, { withFileTypes: true });
|
|
39675
39941
|
} catch {
|
|
39676
39942
|
return [];
|
|
39677
39943
|
}
|
|
39678
|
-
const targets = entries.filter((e) => e.isFile()).map((e) =>
|
|
39944
|
+
const targets = entries.filter((e) => e.isFile()).map((e) => path21.join(dir, e.name)).filter((p) => isRemovableSecret(p, dir, live));
|
|
39679
39945
|
if (targets.length === 0) return [];
|
|
39680
39946
|
return [
|
|
39681
39947
|
{
|
|
39682
|
-
label: `Delete ${targets.length} dead MCP token secret(s) from ${dir}: ${targets.map((p) =>
|
|
39948
|
+
label: `Delete ${targets.length} dead MCP token secret(s) from ${dir}: ${targets.map((p) => path21.basename(p)).join(", ")}`,
|
|
39683
39949
|
destructive: true,
|
|
39684
39950
|
run: async () => {
|
|
39685
39951
|
const failures = [];
|
|
39686
39952
|
for (const p of targets) {
|
|
39687
39953
|
try {
|
|
39688
|
-
await
|
|
39954
|
+
await fs20.unlink(p);
|
|
39689
39955
|
} catch (e) {
|
|
39690
39956
|
const err = e;
|
|
39691
39957
|
if (err.code === "ENOENT") continue;
|
|
@@ -39830,7 +40096,7 @@ var init_orphan_dind = __esm({
|
|
|
39830
40096
|
});
|
|
39831
40097
|
|
|
39832
40098
|
// ../../packages/core/system-doctor/wipe/steps/tier3-targets.ts
|
|
39833
|
-
import
|
|
40099
|
+
import path22 from "path";
|
|
39834
40100
|
function projectSnapshot() {
|
|
39835
40101
|
return [...useStore2.getState().projects];
|
|
39836
40102
|
}
|
|
@@ -39902,7 +40168,7 @@ async function dindTargets(ctx) {
|
|
|
39902
40168
|
return targets;
|
|
39903
40169
|
}
|
|
39904
40170
|
async function requireArchive(ctx, vol) {
|
|
39905
|
-
const archive =
|
|
40171
|
+
const archive = path22.join(backupDirFor(ctx.appSupportDir, ctx.stamp), `${vol}.tar.gz`);
|
|
39906
40172
|
let size = -1;
|
|
39907
40173
|
try {
|
|
39908
40174
|
size = (await ctx.fs.stat(archive)).size;
|
|
@@ -39930,10 +40196,10 @@ var init_tier3_targets = __esm({
|
|
|
39930
40196
|
});
|
|
39931
40197
|
|
|
39932
40198
|
// ../../packages/core/system-doctor/wipe/steps/backup-project-data.ts
|
|
39933
|
-
import
|
|
39934
|
-
import
|
|
40199
|
+
import fs21 from "fs/promises";
|
|
40200
|
+
import path23 from "path";
|
|
39935
40201
|
function dumpAction(p, container, dir, ctx) {
|
|
39936
|
-
const dest =
|
|
40202
|
+
const dest = path23.join(dir, `supabase-${p.id}.pgc`);
|
|
39937
40203
|
return {
|
|
39938
40204
|
label: `Back up the Supabase database of "${p.name ?? p.id}" (pg_dump of ${container}) to ${dest}`,
|
|
39939
40205
|
destructive: false,
|
|
@@ -39949,10 +40215,10 @@ function dumpAction(p, container, dir, ctx) {
|
|
|
39949
40215
|
`Backup of "${p.name ?? p.id}" is truncated: copied ${size} of ${dump2.bytes} bytes to ${dest}`
|
|
39950
40216
|
);
|
|
39951
40217
|
}
|
|
39952
|
-
await ctx.fs.writeFile(`${dest}.sha256`, `${dump2.sha256} ${
|
|
40218
|
+
await ctx.fs.writeFile(`${dest}.sha256`, `${dump2.sha256} ${path23.basename(dest)}
|
|
39953
40219
|
`);
|
|
39954
40220
|
} finally {
|
|
39955
|
-
await
|
|
40221
|
+
await fs21.rm(dump2.dir, { recursive: true, force: true }).catch(() => {
|
|
39956
40222
|
});
|
|
39957
40223
|
}
|
|
39958
40224
|
}
|
|
@@ -39990,7 +40256,7 @@ var init_backup_project_data = __esm({
|
|
|
39990
40256
|
}
|
|
39991
40257
|
for (const vol of [...new Set(volumes)]) {
|
|
39992
40258
|
actions.push({
|
|
39993
|
-
label: `Archive docker volume '${vol}' to ${
|
|
40259
|
+
label: `Archive docker volume '${vol}' to ${path23.join(dir, `${vol}.tar.gz`)} (may take several minutes)`,
|
|
39994
40260
|
destructive: false,
|
|
39995
40261
|
// Never hand-rolled: backupDockerVolume prechecks the volume, archives on
|
|
39996
40262
|
// the long-timeout THROWING seam and proves the result with `gzip -t`.
|
|
@@ -40006,11 +40272,11 @@ var init_backup_project_data = __esm({
|
|
|
40006
40272
|
});
|
|
40007
40273
|
|
|
40008
40274
|
// ../../packages/core/project-context/detect.ts
|
|
40009
|
-
import
|
|
40010
|
-
import
|
|
40275
|
+
import fs22 from "fs/promises";
|
|
40276
|
+
import path24 from "path";
|
|
40011
40277
|
async function isDir(p) {
|
|
40012
40278
|
try {
|
|
40013
|
-
const st = await
|
|
40279
|
+
const st = await fs22.stat(p);
|
|
40014
40280
|
return st.isDirectory();
|
|
40015
40281
|
} catch {
|
|
40016
40282
|
return false;
|
|
@@ -40020,12 +40286,12 @@ async function detectTargets(projectPath) {
|
|
|
40020
40286
|
return {
|
|
40021
40287
|
agents_md: true,
|
|
40022
40288
|
claude_md: true,
|
|
40023
|
-
cursor: await isDir(
|
|
40024
|
-
claude_skills: await isDir(
|
|
40025
|
-
windsurf: await isDir(
|
|
40026
|
-
continue: await isDir(
|
|
40027
|
-
copilot: await isDir(
|
|
40028
|
-
jetbrains: await isDir(
|
|
40289
|
+
cursor: await isDir(path24.join(projectPath, ".cursor")),
|
|
40290
|
+
claude_skills: await isDir(path24.join(projectPath, ".claude")),
|
|
40291
|
+
windsurf: await isDir(path24.join(projectPath, ".codeium", "windsurf")),
|
|
40292
|
+
continue: await isDir(path24.join(projectPath, ".continue")),
|
|
40293
|
+
copilot: await isDir(path24.join(projectPath, ".github")),
|
|
40294
|
+
jetbrains: await isDir(path24.join(projectPath, ".idea"))
|
|
40029
40295
|
};
|
|
40030
40296
|
}
|
|
40031
40297
|
var init_detect = __esm({
|
|
@@ -40039,7 +40305,7 @@ var DOCS_MARKDOWN;
|
|
|
40039
40305
|
var init_docs_generated = __esm({
|
|
40040
40306
|
"../../packages/core/project-context/docs.generated.ts"() {
|
|
40041
40307
|
"use strict";
|
|
40042
|
-
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 \u2014 **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 \u2192 Network** later). Supbuddy adds the CA (Caddy's internal PKI at `~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt`) to your **System keychain** via `sudo security add-trusted-cert`; macOS asks for your password once. (On macOS 15+ that system-wide step is no longer permitted to a background helper, so Install falls back to per-user trust \u2014 see below.) Caddy does **not** self-install trust (the generated Caddyfile sets `skip_install_trust`), so this button is what makes the padlock green \u2014 fully quit and reopen your browser afterward to pick it up. Every Supbuddy domain then gets HTTPS with no per-domain prompts or warnings. (On Windows the install is manual: Supbuddy shows the PowerShell `Import-Certificate \u2026 -CertStoreLocation Cert:\\LocalMachine\\Root` command to run as Administrator.)\n\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 \u2014 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\"* \u2014 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 \u2014 **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 \u2014 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 \u2014 on Firefox/Zen \u2014 set `security.enterprise_roots.enabled` to `true` in `about:config` so the browser reads the System keychain.\n\nIf Supbuddy detects an AI tool that ships its own JavaScript runtime (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, etc.) it will also offer to enable **Bundled-runtime trust** in the same first-run prompt. Those tools don't read the system Keychain (they carry their own Mozilla CA bundle), so without this setup the first OAuth/MCP connection to a `*.test` URL fails with `unable to get local issuer certificate`. Enable it once and Supbuddy keeps it in sync (including across yearly Caddy CA rotation). See the **Bundled-runtime trust** section under Settings \u2192 General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings \u2192 Network** tab.\n\n### 3. Add your first project\n\nClick **Add project** in the Configure tab and pick a project root folder (the one with `package.json` and/or `supabase/config.toml`). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:\n\n- Supabase Kong \u2192 `api.<project>.test`\n- Supabase Studio \u2192 `studio.<project>.test`\n- Supabase Inbucket / Mailpit \u2192 `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) \u2192 `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings \u2192 General \u2192 Default TLD**.\n\n> **`.local` is fine again, from 3.5.18.** Earlier versions made every managed domain resolve slowly \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2192 Network**. Supbuddy inserts a `pfctl` redirect rule into `/etc/pf.conf` (asks for sudo once) and reports whether the redirect is actually being enforced via a live 443 probe \u2014 not merely that the rule is on disk. If port forwarding is on but 443 won't connect, see [Port forwarding is on but 443 won't connect](#port-forwarding-is-on-but-443-wont-connect).\n\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 \u2014 most often when no mapping is enabled yet, which generates a Caddyfile with no site blocks \u2014 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 \u2192 port pair (e.g. `api.acme.test \u2192 54321`). Auto-generated mappings are tied to a detected service or app; you can also create manual ones.\n- **Isolation mode**: per-project. One of:\n - `thin` (lightweight, **the default for newly registered projects**): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own **port block** and a unique Compose `project_id`, written into that project's `supabase/config.toml`. That's what lets several Supabase projects run **at once on the shared daemon**, each reached by name (`api.<project>.test`, `studio.<project>.test`). Apps bind a **per-project loopback IP** (127.0.0.2, 127.0.0.3, \u2026) so every project's dev servers keep their canonical ports \u2014 each project gets its *own* `:3000`. Start dev servers with `supbuddy run -- <dev command>` so they bind that IP. Supbuddy owns those config.toml keys while the project is `thin` and restores them the moment you switch back to `host`.\n - `host`: everything shares `127.0.0.1` and the stock ports. Dev-server ports collide across projects, and only one host-mode Supabase project can run at a time (the standard `supabase start` constraint). Use `host` **only when the project's Supabase stack is already running on the host independently of Supbuddy** (you run `supabase start` yourself and don't want Supbuddy re-porting `config.toml`). MCP registration (`register_project`) detects that case and keeps such projects on `host` automatically; in the app's Add-project dialog, pick **Host** in the Environment section yourself.\n- **Active vs inactive**: any project can be \"active\" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.\n\n## Project cards (Configure tab)\n\nEach registered project appears as a card in the Configure tab. Cards have a single-row header that's always visible and a tab-based body that expands on click.\n\n### Header\n\nReading left to right:\n\n- **Expand chevron** + **project name**: click to expand/collapse the card.\n- **Status indicator**: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem's state.\n- **Tech badges**: e.g. `TurboRepo`, `Supabase` (shown when detected).\n\n**Supabase connection warning.** When a project's app `.env` is missing the\nSupabase connection vars, or they've gone stale relative to the live target\n(e.g. after switching isolation, which republishes ports), the card shows a\n`supabase env: not connected` / `supabase env: out of date` pill. Click it to\nopen Connect and push fresh values, or choose **Ignore for this project**.\n- **Env mode chip**: read-only `Host` or `Thin` label (matching the project's isolation mode). To switch modes, open the **Supabase** tab and use the **Environment** section at the top.\n- **Issues counter**: red for errors, amber for warnings. Click to open the **issues popover** (see below). Hidden when there are no issues.\n- **Warnings chip**: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.\n- **Enable toggle** (right edge): turn the project's proxy on/off without deleting it.\n- **\u22EF actions menu** (right edge): every project-level action: **Edit project**, **Rescan**, **Re-check configs** (re-runs the connection/env drift check for this project), **Select folder**, **Export bundle**, and **Delete project**.\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings. Each issue shows a severity icon, title, optional detail, and a **\u2192 open {tab}** link. Clicking the link jumps to the relevant tab and closes the popover.\n\n### Body tabs (when expanded)\n\nThe body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header's **\u22EF menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain \u2192 :port` (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed **edit** / **delete** / **access** (LAN / Tailscale state) actions and the per-mapping **toggle** to the right edge. A **Map** CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.\n\n#### Supabase (shown when Supabase is detected)\n\n**Environment section (top):** host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see [Migrating a legacy Isolated (VM) project to Thin](#migrating-a-legacy-isolated-vm-project-to-thin)).\n\n**Action bar:** Start, Stop, Restart buttons; a first-class **Connect** button (cyan, opens the connection panel for `.env` generation / merge); and a **More** menu with **Config editor** and **Details**.\n\n**Config editor: secret extraction.** When you save a `supabase/config.toml` that contains a secret-bearing value inline (e.g. an SMTP password under `[auth.email.smtp]`, an OAuth `secret`, or any `*_key`/`auth_token`), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root `.env.local`). The value is written there and replaced in `config.toml` with an `env(SUPABASE_\u2026)` reference, so secrets never land in git. Supbuddy injects those `SUPABASE_`-prefixed values back into the `supabase start` environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)\n\n**Service rows** (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.\n\n#### Compose (shown when Compose services are detected)\n\n**Action bar:** Start, Stop, Restart. **Service rows** are read-only (status dot, name, URL). Add-on services declared in `supbuddy.addons.yml` (see **Add-on Compose services**) appear here alongside the base stack and in `get_compose_status` over MCP.\n\n#### Other (shown when non-Supabase, non-Compose services are detected)\n\nRead-only service rows: status dot, name, URL.\n\n#### Scripts (shown when scripts are detected)\n\nBookmarked scripts appear in a **Quick Access** group at the top; remaining scripts appear under **Other Scripts**. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.\n\n#### AI Tools\n\nWraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target **scope** (global / local), advanced options, and recent activity. See [Per-project AI context sync](#per-project-ai-context-sync) for what global vs. local means.\n\n> Project-level actions (**Edit**, **Rescan**, **Re-check configs**, **Select folder**, **Export bundle**, **Delete**) are no longer a tab. They live in the header's **\u22EF actions menu**.\n\n---\n\n## Multiple Supabase projects (the main use case)\n\nThe reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 Inbucket). Two projects on the same machine collide; you must `supabase stop` one before `supabase start`-ing the other.\n\nTwo ways to break that constraint, picked per project in the **Supabase** tab \u2192 **Environment** section:\n\n### Thin (lightweight, recommended)\n\nSwitch a project to **Thin**. Supbuddy assigns it a free port block (in the `55000+` range), writes those ports plus a unique Compose `project_id` into its `supabase/config.toml`, and runs `supabase start` on your **normal host Docker**, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (`api.acme.test`, `studio.acme.test`, `mail.acme.test`). Switch back to **Host** and Supbuddy restores the original `config.toml` and stops just that project's stack.\n\nThis is the lightest, fastest option and the right default for most setups \u2014 which is why **newly registered projects default to Thin**. One caveat: if your `config.toml` omits a port key (e.g. `[inbucket] smtp_port`), Supbuddy can't relocate a port that isn't declared, so that one service falls back to its stock port. That is fine for a single project, but spell those keys out if two Thin projects need the same service.\n\n### Dev servers on Thin: every project keeps its own `:3000`\n\nA Thin project also gets its own **loopback IP** (127.0.0.2, 127.0.0.3, \u2026, persisted per project). Its app dev servers bind that IP instead of `127.0.0.1`, so canonical ports never collide across projects \u2014 five Next.js apps in five projects can all run on `:3000` at once, and Supbuddy's proxy routes each `web.<project>.test` to its project's IP.\n\nStart dev servers through the launcher:\n\n```bash\nsupbuddy run -- next dev # binds -H <project loopback IP>, stays on :3000\nsupbuddy run -- vite # injects --host <ip> --strictPort\nsupbuddy run --print -- next dev # show what would run, without running it\n```\n\n`supbuddy run` reads the project's IP from the nearest `.supbuddy/meta.json` (`loopbackIp`, written when Thin is enabled), ensures the loopback alias exists, injects the right bind flag for the detected framework, and execs your command. It prints one concise line with the project's Caddy-proxied URL (e.g. `[supbuddy] \u2192 https://web.<project>.test`) \u2014 the address you should actually open. For **Next and Vite** it also hides the dev server's own `- Local:/- Network:` banner (which only echoes the raw loopback IP `127.0.0.N:<port>`, bypassing Supbuddy's HTTPS proxy): those two lines are filtered out of the piped output, every other line passes through untouched, and colours are preserved via `FORCE_COLOR` (stdin stays interactive). Other frameworks pass through with no filtering. When a project has several app mappings, it matches the one whose port equals the dev server's port (from `--port`/`-p` or the framework default), else lists them all. Make it the project's `dev` script (`\"dev\": \"supbuddy run -- next dev\"`) so nobody \u2014 humans or agents \u2014 has to remember it. **Never move an app to a nonstandard port because `127.0.0.1:3000` is busy**; that port belongs to another project's IP space.\n\n### When to stay on Host\n\nKeep a project on **Host** only when its Supabase stack runs on the host *independently of Supbuddy* \u2014 you run `supabase start` yourself on the stock ports and don't want Supbuddy rewriting `config.toml`. MCP registration (`register_project`) detects a stack like that (running containers for the project's `config.toml` `project_id`) and keeps the project on Host automatically; in the app's Add-project dialog, pick **Host** in the Environment section for such projects. Stop the stack (`supabase stop`) and switch to Thin whenever you're ready.\n\n### Running them all at once\n\nRegister as many projects as you want, and all of them can be \"active\" (proxied) at the same time. There's no limit. A Thin project's stack restarts in seconds; a Host project needs the standard `supabase start` cycle.\n\n### Migrating a legacy Isolated (VM) project to Thin\n\nIf you created a project in an older version of Supbuddy that used the now-retired **Isolated (VM)** mode, Supbuddy detects it on launch and offers a one-way, guided migration to **Thin**. The migration wizard appears in the **Supabase** tab's Environment section for any project still flagged as VM.\n\nThe migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there's no way to switch back (but your data is intact in the Thin stack).\n\nOver MCP, three tools handle the migration bridge:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode awaiting migration.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration (dump, restore, verify).\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.\n\n## Custom domains & TLDs\n\nEvery mapping resolves through Supbuddy's built-in DNS server on port 5353. By default the TLD is `.test` (an IETF-reserved TLD safe for local use). You can change the default in **Settings \u2192 General \u2192 Default TLD** to `local`, `dev`, or anything else; existing mappings are migrated to the new TLD on save.\n\nFor host resolution, Supbuddy *does not* use `/etc/hosts` for wildcards; it runs a DNS resolver. macOS's default resolver only queries port 53; Supbuddy installs a per-project resolver file under `/etc/resolver/<project-domain>` (e.g. `/etc/resolver/myapp.local`) pointing at `127.0.0.1:5353`. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like `.local` (which Bonjour/mDNS owns). You'll be prompted for sudo the first time this changes.\n\nResolver files exist only for domains the proxy actually serves \u2014 the same set that gets a Caddy site block: enabled mappings that are either standalone or under an **enabled** project. Disable or delete a project and its resolver file is removed with its routes (one sudo prompt, and only when something really changed), so its domains go back to failing as \"server not found\" instead of resolving into a TLS handshake error from a proxy that has nothing to serve. Enabling it again writes the file back; so does restarting the proxy.\n\n### Per-project TLD\n\nBy default every project's domain uses the global TLD (Settings \u2192 Default TLD, e.g. `.test`). A single project can opt into its **own** TLD \u2014 set the suffix in the project dialog, pass `tld` to the `register_project` / `update_project` MCP tools, or use the CLI: `supbuddy project add <path> --tld=portal` when registering, or `supbuddy project set <project> --tld=portal` on an existing one (`--tld=` with an empty value clears the override). That project's base domain and all its subdomains then live on the override TLD (e.g. `cueplusplus.portal`, `web.cueplusplus.portal`) while every other project stays on the global default. The override is durable across restarts and is unaffected when you change the global TLD. Prefer `.test` or a vanity label like `.portal`; avoid `.local` (it collides with macOS mDNS/Bonjour).\n\n### LAN sharing\n\nWhen LAN sharing is enabled (Settings \u2192 Network), Supbuddy binds Caddy to `0.0.0.0` instead of `127.0.0.1` and runs an mDNS responder so other machines on your local network can reach your dev servers via `<hostname>.local`. Useful for testing on your phone or another laptop without setting up Tailscale.\n\n**`.local` TLD + LAN sharing:** macOS reserves the `.local` namespace for Bonjour/mDNS (RFC 6762), and macOS's TCP stack short-circuits self-connections to your own LAN IP via the loopback path *without consulting `pf`*, so the obvious \"redirect lo0 \u2192 my LAN IP\" trick can't fix it. Supbuddy's mDNS responder works around this by **ignoring queries that originate from this machine**, letting the OS resolver fall through to `/etc/resolver/<project-domain>` (which routes to `127.0.0.1` where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: `.local` works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to `.test`, you can switch back.\n\nIf `studio.<project>.local` (or similar) doesn't load: open the Configure tab. A red banner will tell you whether it's a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.\n\n### Tailscale\n\nIf you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.\n\n## Monorepo support\n\nSupbuddy auto-detects these monorepo layouts when scanning a project root:\n\n- Turborepo (presence of `turbo.json`)\n- pnpm workspaces (`pnpm-workspace.yaml`)\n- npm/yarn workspaces (`workspaces` field in root `package.json`)\n- Common folder layouts: `apps/*`, `packages/*`, `services/*`, `sites/*`\n\nEach detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: `apps/*`, `packages/*`, `services/*`, `sites/*`, `db/`, `db/*`, `database/`, `database/*`, `packages/backend`, `packages/db`, `packages/database`.\n\n### Detected app frameworks\n\nPort detection looks for the framework dependency in `package.json` and combines that with: explicit `-p`/`--port` in the dev script, `PORT=` env in the dev script, or a config file read. If none of those resolve, the framework default is used:\n\n| Framework dependency | Default port |\n| --- | --- |\n| `next` | 3000 |\n| `vite` | 5173 |\n| `@remix-run/dev`, `@remix-run/serve` | 3000 |\n| `astro` | 4321 |\n| `nuxt`, `nuxt3` | 3000 |\n| `@sveltejs/kit` | 5173 |\n| `@angular/core` | 4200 |\n| `@nestjs/core` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | none (must be explicit in dev script) |\n\n### Server Actions allowedOrigins audit\n\nFor Next.js apps, Supbuddy reads your `next.config.{ts,mts,js,mjs,cjs}` and extracts the hosts in `experimental.serverActions.allowedOrigins`. If a mapped subdomain is missing from that list, the project's **warnings chip** flags `next.config: N origins missing`; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the **Apps** tab (the chip's \"open apps\" jump) where the affected app shows the warning with a **Fix** button.\n\nThe Fix button opens a dialog with a paste-ready snippet and an **Apply\u2026** button: click it to see a unified diff of the change Supbuddy will make to your `next.config`, then **Confirm & write** to apply it. Supbuddy handles the four common config shapes (existing `allowedOrigins` array, existing `serverActions` block without it, existing `experimental` block without `serverActions`, or no `experimental` at all). The edit is strictly additive: existing array entries are kept verbatim, including spreads (`...devHosts`), identifiers and comments, and only the missing origins are appended.\n\nIf `allowedOrigins` (or `serverActions`, or `experimental`) is set to something other than a plain array/object literal \u2014 an identifier, a function call, a ternary, `[...] as string[]` \u2014 Supbuddy **refuses to patch** rather than guess, and the dialog says so along with the exact origins to add. This is deliberate: a wrong rewrite would produce a duplicate key (TypeScript `TS1117`) that breaks your build long after the fact, so the fallback is the copyable snippet. Use it and edit by hand.\n\nAfter write, Supbuddy rescans the project so the warning disappears immediately. Restart your dev server for the change to take effect; Next.js does not hot-reload `next.config`. Over MCP the same audit is exposed as `preview_next_origins` / `apply_next_origins`; both return `ok: false` with an explanation in the refusal case, and `apply_next_origins` never writes a file it cannot verify.\n\n### Next.js cross-origin dev requests (allowedDevOrigins)\n\nSupbuddy proxies your dev server but **passes the browser's real `Origin` header through** (it no longer rewrites `Origin` to the upstream address). That's required so Server Actions and other origin checks see the actual page origin \u2014 but it means **Next.js 15.3+ and 16** dev servers, which validate cross-origin dev requests against `allowedDevOrigins` (defaulting to `localhost`), now treat a request arriving on a Supbuddy domain (or a Thin project's `127.0.0.N` loopback IP) as cross-origin and can reject it. Add your Supbuddy domain to `allowedDevOrigins` in `next.config`:\n\n```js\n// next.config.js\nmodule.exports = {\n allowedDevOrigins: ['web.myproject.test'],\n}\n```\n\nRestart the dev server afterward; Next.js does not hot-reload `next.config`. This is separate from `experimental.serverActions.allowedOrigins` (the Server Actions CSRF list above) \u2014 15.3+/16 may need both.\n\n### Vite allowedHosts audit\n\nFor Vite apps, Supbuddy reads your `vite.config.{ts,mts,cts,js,mjs,cjs}` and extracts `server.allowedHosts`. If a mapped host isn't covered, the **warnings chip** flags `vite: N hosts blocked`; Vite's dev server otherwise rejects proxied requests for unknown hosts with `Blocked request. This host (\"\u2026\") is not allowed.` (403). A `.your-project.local` entry counts as covering every subdomain, so an existing wildcard suffix doesn't trigger a false warning.\n\nLike the Next.js audit, the affected app's **Fix** button on the **Apps** tab opens a dialog with a paste-ready snippet and an **Apply\u2026** button that previews a unified diff and writes `server.allowedHosts` into your `vite.config` (handling an existing `allowedHosts` array, an existing `server` block without it, or no `server` block at all; `allowedHosts: true` is left untouched). The edit is strictly additive \u2014 existing entries, spreads and comments are kept verbatim and only missing hosts are appended \u2014 and, exactly as with the Next.js audit, Supbuddy **refuses to patch** when `allowedHosts` or `server` is set to anything other than a plain array/object literal, pointing you at the snippet instead of risking a duplicate-key build break. After write, Supbuddy rescans so the warning clears. Restart your dev server for the change to take effect; Vite does not hot-reload `vite.config`.\n\n## MCP setup (AI agents)\n\nSupbuddy ships a built-in MCP server on `http://127.0.0.1:9877/mcp` with static Bearer-token auth. Five clients have one-click install; any other MCP-compatible tool can be configured manually with the same URL + token.\n\nOpen **Settings \u2192 MCP \u2192 Add client**, pick the client kind, and Supbuddy generates a token, edits the client's config file, and backs up the original (`<file>.supbuddy-backup` next to it). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (**Settings \u2192 MCP \u2192 Clients**: install, edit scopes, set-primary, rotate token, revoke) drives each client from the app.\n\n### Auto-install paths\n\n| Client | Config file | Transport |\n| --- | --- | --- |\n| Claude Code | `~/.claude.json` (user) or `<project>/.mcp.json` (project) | HTTP |\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | stdio shim via `npx -y @supbuddy/mcp@latest` |\n| Cursor | `~/.cursor/mcp.json` (user) or `<project>/.cursor/mcp.json` (project) | HTTP |\n| Codex CLI | `~/.codex/config.toml` (adds an `[mcp_servers.supbuddy]` block) | HTTP |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` | HTTP |\n\n### MCP tool surface\n\nThe MCP server has full read and write access:\n\n- Read tools (`list_mappings`, `list_projects`, `get_health`, `get_compose_status`, `list_pending_vm_migrations`, etc.), with env values and request bodies included.\n- `get_client_capabilities` and `request_scope_elevation` (scope discovery + user-approved grant).\n- `read_env_file`, `tail_request_logs`, `watch_audit_log`.\n- Write tools: `create_mapping`, `delete_mapping` (soft-delete), `register_project`, `update_project`, `set_supabase_config_path`, `start_proxy`, `start_supabase`, `stop_supabase`, `restart_supabase`, `switch_isolation`, `migrate_vm_to_thin`, `finish_vm_migration`, `start_compose`, `stop_compose`, `restart_compose`, `scaffold_addons`, `seed_addons`, `write_env_file`, `copy_env_var`, `write_supabase_config`.\n- Scripts tools (`list_scripts`, `start_script`, `stop_script`, `restart_script`, `bookmark_script`, `tail_script_logs`); see *Scripts MCP tools* below.\n- Extended Supabase tools: `init_supabase`, `validate_supabase_config`, `list_supabase_backups`, `restore_supabase_backup`, `cancel_supabase_start`, `force_recreate_supabase`, `restart_supabase_container`, `get_supabase_analytics`, `set_supabase_analytics`.\n- Bundle (export/import a project's full config): `export_bundle`, `import_bundle`, `validate_bundle`.\n- Supbuddy Cloud (opt-in, per-project): `cloud_sign_in`, `push_to_cloud`, `get_cloud_status`, `preview_cloud_env`, `cloud_teardown`, plus live sync (`cloud_sync_start`, `cloud_sync_status`, `cloud_sync_stop`) \u2014 push a project (with its Supabase schema + data) to a hosted cloud stack and control it. The `cloud` link (`{ projectId, stackId, pushedAt, url }`) also appears on `get_project` / `list_projects`, so any client sees which projects are in the cloud. `get_cloud_status` also returns a `box` summary \u2014 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 \u2014 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 \u2014 meaningless inside a box), **remote** (correct as-is in both places), **secret**, or **plain**, each with a reason in plain words, plus `needs_attention` \u2014 the count of local ones, the only number that implies an action. It is read-only and changes nothing. **Secret values are never returned** \u2014 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 \u2192 8443` redirect is actually live \u2014 probed, not remembered. `enforced` is `null` whenever the probe would be meaningless \u2014 the proxy is stopped, or nothing is listening on the HTTPS port \u2014 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 \u2014 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 \u2014 a declined prompt, a timeout, or a ruleset that fails validation \u2014 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`) \u2014 it mutates nothing. `doctor_fix` ( `{ check_ids: [...] }` ) applies the opt-in repairs for those checks; it's **system-scoped and confirm-gated** (a modal, exactly like `uninstall_ca`), so a read-scoped client can't trigger a fix and an agent can't silently run a destructive repair. Backs `supbuddy doctor` / `doctor --fix` (see *System doctor*).\n- System reset: `system_wipe` ( `{ tier: \"soft\" | \"deep\" }` , scope `system`) runs the tiered reset described under *System reset*. It is gated **twice**: it always returns a plan first \u2014 even for `auto_apply` clients \u2014 whose `side_effects` are the literal manifest the wipe will execute, and the subsequent `apply` still blocks on a user confirmation modal. `tier: \"full\"` is **rejected**: it deletes the credentials the caller is authenticating with, and its final steps (uninstalling the service, removing the app-data directory) can't run inside the daemon \u2014 run `supbuddy reset --tier=full` in a terminal instead.\n- Multiple MCP clients can connect simultaneously. The same MCP-HTTP surface backs the headless **CLI** (see *Command-line interface* below).\n\n### Scopes: discovery & self-service elevation\n\nEach MCP client holds a set of **scopes** (`read`, `log_tail`, `mappings`, `projects`, `services`, `config`, `system`, `apply`) chosen when it's added. A tool call that needs a scope the client lacks fails with `scope_denied`, whose payload now carries a `user_message` and `details.remediation` pointing at the fix.\n\n- `get_client_capabilities` ( `{ tool? }` ) returns the calling client's `granted_scopes` and `available_scopes`. Pass a `tool` name to get `{ required_scope, required_feature, can_call, reason? }` so an agent can pre-flight a call instead of probing by hitting `scope_denied`.\n- `request_scope_elevation` ( `{ scopes: [...] }` ) asks the **user** to grant the named scopes. Supbuddy shows a blocking approval dialog; on approval the scopes are added to the client. Already-granted scopes short-circuit without a prompt.\n\nYou can also review and edit any client's scopes from the GUI: **Settings \u2192 MCP \u2192 Clients** lists each client's granted scopes inline and exposes a **Scopes** button that opens the same scope editor used when adding a client.\n\n### Registering a project via MCP\n\n`register_project` takes a `root_path` (required), an optional `label`, `auto_scan` (default `true`), and an optional `isolation` (`'thin'` or `'host'`). It registers the project the same way the GUI's \"Add project\" flow does:\n\n- Derives a base domain as `<slug>.<defaultTld>` from the label (or the folder name), e.g. `staffhub.test`.\n- Records both the project `path` and `rootPath` so the project is visible to the proxy, scans, and file tools alike.\n- Scans the folder (unless `auto_scan: false`) for apps, services, scripts, and package manager.\n- Creates per-app subdomain mappings from the discovered apps (e.g. `site.staffhub.test \u2192 :3400`), derives the host service subdomains (`api.`, `studio.`, \u2026), and reloads Caddy.\n- **Defaults to `thin` isolation**: the project gets its own loopback IP so its dev servers keep canonical ports (`:3000`) with no cross-project collisions \u2014 run them with `supbuddy run -- <dev command>`.\n\nA project lands on `host` in exactly three cases:\n\n1. You passed `isolation: 'host'`, or **Settings \u2192 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** \u2014 switching would rewrite its `config.toml` ports and orphan that stack, so registration keeps it on host.\n3. Thin was **attempted and failed** \u2014 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 \u2014 agents should read it instead of assuming.\n\n### Switching isolation over MCP\n\n`switch_isolation` ( `{ project_id, target_mode: 'host' | 'thin', auto_start? }` ) moves an existing project between **host** and **thin** mode. It is a destructive tool, so unless the client has auto-apply it **stages a plan** rather than switching \u2014 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 \u2014 except `system_wipe`, which always stages.\n\nA staged plan carries two fields an agent should act on:\n\n- **`can_apply: true`** \u2014 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** \u2014 never read that field as a denial.\n- **`__apply_via`** \u2014 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> \u2026`. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project's default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.\n\n### `supbuddy.addons.yml` format\n\nA valid Compose fragment (a standard `services:` map) plus an optional Supbuddy-only `x-supbuddy:` extension block. A plain `docker compose up` ignores `x-supbuddy:`, so the file stays usable without Supbuddy. Today `x-supbuddy` supports a one-shot **seed** step:\n\n```yaml\nservices:\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\nx-supbuddy:\n seed:\n service: redis\n command: [\"redis-cli\", \"ping\"] # explicit argv, runs once after services are healthy\n runOnce: true\n```\n\nThe seed step runs **once** after the add-on services are up and healthy. It's idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the `seed_addons` MCP tool.\n\n### MCP tools\n\n- `scaffold_addons` ( `{ project_id }` ): scope `config`. Creates a starter `supbuddy.addons.yml` if the project doesn't have one. Never clobbers an existing file.\n- `seed_addons` ( `{ project_id, force? }` ): scope `services`. Runs the declared `x-supbuddy.seed` step. Idempotent unless `force: true`.\n- `tail_service_logs` ( `{ project_id, service }` ): scope `log_tail`. Streams a Compose/add-on service's container logs over SSE (like `tail_request_logs`, but for container stdout/stderr).\n- `watch_supabase` ( `{ project_id }` ): scope `log_tail`. Streams a project's live Supabase start/stop/restart progress over SSE: `operation` (status + message), `progress` (image-pull/service snapshots), and `log` (raw lines, VM projects). The stream ends on a terminal status. Backs `supbuddy supabase start --follow`.\n\n### Scripts MCP tools\n\nScripts detected in a project (e.g. `dev`, `build`, `test`) are controllable over MCP:\n\n- `list_scripts` ( `{ project_id }` ): scope `read`. Returns all detected scripts with their current status and bookmark state.\n- `start_script` ( `{ project_id, script }` ): scope `services`. Starts the named script process.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process.\n- `bookmark_script` ( `{ project_id, script, bookmarked }` ): scope `services`. Pins (`bookmarked: true`) or unpins a script in the Quick Access group.\n- `tail_script_logs` ( `{ project_id, script }` ): scope `log_tail`. Streams the named script's stdout/stderr over SSE.\n\n### `get_compose_status` shape\n\n`get_compose_status` ( `{ project_id }` ) returns per-service status, not just whether Compose is installed:\n\n```json\n{\n \"project_id\": \"\u2026\",\n \"compose_installed\": true,\n \"running\": true,\n \"services\": [\n { \"name\": \"redis\", \"status\": \"running\", \"health\": \"healthy\", \"ports\": [\"6379:6379\"], \"image\": \"redis:7-alpine\", \"container_id\": \"\u2026\", \"source\": \"addons\" }\n ],\n \"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 \u2014 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** \u2192 one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** \u2192 `~/.cursor/skills/supbuddy/SKILL.md`. The global skill self-scopes: it only acts when the working directory has a `.supbuddy/` folder, and resolves the active project from that folder's `meta.json`.\n- All other targets (`windsurf`, `continue`, the `AGENTS.md`/`CLAUDE.md`/Copilot managed blocks, JetBrains) stay **local**: their \"global\" files are shared user files, so Supbuddy won't overwrite them.\n- Each target has a **scope** setting: `auto` (default: global for the Claude/Cursor skills, local for everything else), `global`, `local` (force per-project, useful if you commit the file for teammates), or `off`. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does **not** auto-remove these global files; delete them manually from `~/.claude/skills/supbuddy/` and `~/.cursor/skills/supbuddy/` if needed.\n- The always-loaded `CLAUDE.md`/`AGENTS.md` managed block stays local as a safety net so agents stay aware even if the on-demand global skill doesn't auto-activate.\n\nSync modes per project:\n\n- **Auto**: Supbuddy regenerates the files whenever mappings, services, or project state change.\n- **Manual only**: files are only written when you click **Sync now** (or use the tray's *Sync AI context for all projects*).\n- **Off**: nothing is written.\n\nThe collapsed header shows an at-a-glance status pill: mode (`auto` / `manual` / `off`), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn't present) appear greyed out in the **Detected targets** list inside the panel.\n\n## Supbuddy Cloud\n\nPush a project \u2014 its Supabase schema **and data** \u2014 to a hosted cloud dev-stack (its own full self-hosted Supabase \u2014 Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway \u2014 as an isolated graph of machines on a per-tenant private network) and control it from the app, the CLI, or MCP. **Opt-in and per-project:** nothing cloud-related appears in a project until you've signed in.\n\n- **Invite-only, for now** \u2014 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 \u2014 only the operator can.\n- **Get started** \u2014 the top bar shows a **Get started with Supbuddy Cloud** strip; sign in (email/password) there. Once signed in it becomes **Open cloud** (opens [cloud.supbuddy.app](https://cloud.supbuddy.app) in your browser). Sign-in state + the Claude connection also live under **Settings \u2192 Cloud**.\n- **Push a project** \u2014 after signing in, each project's \u22EF menu gains **Push to cloud\u2026**. The 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* \u2014 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** \u2014 a **\u2601** badge appears on its row; click it (or \u22EF \u2192 **Open in cloud**) to open the stack in the web app.\n- **CLI / MCP** \u2014 the same flow headless: `supbuddy cloud login|push|status|teardown` (password via arg or `SUPBUDDY_CLOUD_PASSWORD`), or the `push_to_cloud` / `get_cloud_status` / `cloud_teardown` / `cloud_sign_in` MCP tools. `project ls` marks pushed projects with \u2601, and `get_project` / `list_projects` carry the `cloud` link. `cloud_teardown` (and the \u22EF teardown) destroy the remote stack and unlink it locally \u2014 routed through the same plan/apply gate as other destructive tools.\n- **Service breadth** \u2014 a self-hosted push provisions the **full** Supabase stack by default. Pass `push_to_cloud`'s `supabase_services: \"minimal\"` (MCP) to opt down to a lean db/auth/REST stack instead.\n- **Idle auto-stop** \u2014 a running cloud stack that reports no activity for ~30 minutes is automatically **stopped** to save cost (its data + config persist; start it again from the web app). A background reaper also reconciles any stack whose machines went missing.\n- **Web console** \u2014 [cloud.supbuddy.app](https://cloud.supbuddy.app) lists your org's stacks; open one for its per-service health, live status, and **start / stop / restart / tear down** controls, plus a **Recent activity** feed of control-plane events. **Push to cloud** in the console provisions a stack from a GitHub `owner/repo` (self-hosted or bring-your-own Supabase; full or minimal service set) \u2014 the code-only path; pushing a local project *with its data* still goes through the desktop app / CLI.\n\n### 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` \u2014 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 \u2014 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 \u2014 you will see *\"The name is changed, but 1 box is still\n moving to it\u2026\"* 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 \u2014 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) \u2014 the box's phase and uptime, then four sections:\n - **Apps** \u2014 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 \u2014 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** \u2014 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** \u2014 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** \u2014 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** \u2014 *\"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** \u2014 the original compact tree, still available below the panel and collapsed by default.\n\n### Live sync (local \u2194 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** \u2014 in the app, a cloud project's \u22EF menu has **Start live sync\u2026** 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** \u2014 `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 \u2014 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 *\"\u2026has 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 \u2014 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 \u2014 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** \u2014 `.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** \u2014 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 \u2014 nothing outlives\n a destroyed stack.\n- **Seeing it** \u2014 a syncing project shows its state beside the \u2601 badge: *First sync\u2026*, *In sync*,\n *n conflicts*, or *Paused \u2014 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 \u2014 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 \u2014 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>` \u2014 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 \u2014 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 \u2014 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 \u2014 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>` \u2014 on a Thin project, binds the dev server to the project's loopback IP (from `.supbuddy/meta.json`) so it keeps its canonical port (e.g. `supbuddy run -- next dev` stays on `:3000`) |\n| Health / proxy | `status`, `doctor [--fix]` (health & drift scan \u2014 see *System doctor*), `reset [--tier=soft\\|deep\\|full]` (tiered system reset \u2014 see *System reset*), `proxy status\\|start\\|stop\\|restart` |\n| Mappings | `map ls\\|add\\|get\\|set\\|enable\\|disable\\|rm\\|restore` |\n| Projects | `project ls\\|add\\|get\\|scan\\|set\\|enable\\|disable\\|rm\\|restore\\|env\\|refresh-context` |\n| Supabase | `supabase start\\|stop\\|restart\\|status <proj>` (add `--follow` to stream live progress), `supabase config apply <proj> <file>` |\n| Cloud | `cloud login <email> [<pw>]` (or `SUPBUDDY_CLOUD_PASSWORD`), `cloud push <proj> [--repo=owner/repo] [--force]`, `cloud status [<proj>]`, `cloud teardown <proj>` \u2014 push a project (with its Supabase data) to a hosted cloud stack; `project ls` marks pushed projects with \u2601 |\n| Compose | `compose up\\|down\\|restart\\|status\\|logs <proj> [svcs]` |\n| Scripts | `scripts ls\\|start\\|stop\\|restart\\|logs\\|bookmark <proj> [script]` |\n| Isolation | `isolation switch <proj> <host\\|thin>`, `isolation pending-migrations`, `migrate start\\|finish <uuid>` |\n| Certificates | `ca status\\|install\\|uninstall` |\n| Env files | `env copy <src> <key> <target>`, `env write <path> <K=V>\u2026` |\n| Settings | `settings get`, `settings set --json <patch>` |\n| MCP | `mcp add [<agent>]` (register Supbuddy into a coding agent: interactive, or `--write`/`--print`/`--prompt`), `mcp ls`, `mcp revoke <id>`, `mcp approvals apply\\|cancel <id>` |\n| Host / network | `connect`, `trust`, `tailscale`, `dns`, `pf` (port-forwarding) |\n| Logs | `logs requests [-f]`, `logs audit [-f]`, `logs get <id>` |\n| Account | `account`, `caps`, `addons scaffold\\|seed <proj>` |\n| Dashboard | `tui` (alias `dash`) |\n\nGlobal flags: `--json` (machine-readable output), `--yes` (skip confirmations), `--quiet`, `--url`/`--token` (attach to a specific/remote daemon instead of auto-discovery), `--state-dir` (override the shared dir), `--timeout`, and `-f`/`--follow` for streaming log commands and live `supabase start|stop|restart` progress.\n\nDestructive operations go through the same **plan \u2192 apply** gate as MCP (see *Plan / apply for destructive tools*); the CLI's control token is granted auto-apply, so they execute directly.\n\n### Live dashboard (TUI)\n\n```bash\nsupbuddy tui # or: sup dash\n```\n\n`supbuddy tui` opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project's isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press `r` to refresh, `q` to quit. It needs a running daemon (`supbuddy daemon --detach`); if none is found it tells you so.\n\n### System doctor\n\n```bash\nsupbuddy doctor # read-only scan; prints findings by severity\nsupbuddy doctor --fix # scan, show the repair manifest, confirm (y/N), then apply\nsupbuddy doctor --fix --only=ca-not-trusted # restrict repairs to specific check ids (comma-separated)\nsupbuddy doctor --fix --yes # skip the interactive confirm (scripting / CI)\n```\n\n`supbuddy doctor` runs a **read-only** health and drift scan and prints its findings grouped by severity \u2014 **critical**, **warning**, **info** \u2014 each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing, 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 \u2014 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 \u2014 something is definitely broken |\n| `2` | The scan **could not complete** \u2014 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 \u2014 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** \u2014 one line per fixable finding, taken from the scan you just saw \u2014 and, unless you pass `--yes`, asks `Apply these fixes? [y/N]` (default **No**) before touching anything. (The desktop app's doctor panel shows the finer-grained repair *actions* themselves; the CLI lists the findings those actions belong to.) `--only=<comma,ids>` restricts the repair to specific check ids; `--yes` skips the prompt for non-interactive use. This is the **confirm-before-harm** contract: the scan is read-only, and every repair is opt-in and gated. Fixes that need elevated access prompt for your password when they run.\n\nA repair that ends up doing nothing is reported as such, never as success: if a requested check's finding is already gone, is advisory, can't be re-checked, or names an unknown id, it's listed under **NOT APPLIED** and the command exits non-zero.\n\n**An aborted `--fix` also exits non-zero (`1`).** Declining the confirmation applies nothing, so every finding is still there \u2014 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 \u2014 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 \u2014 no projects, mappings, settings or MCP clients | Copies the file aside as `state.json.corrupt-<timestamp>` so you can hand-recover it. Nothing is deleted or rewritten |\n| `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 \u2014 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 \u2014 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 \u2014 no auto-fix.** `supbuddy proxy restart` rewrites the resolver files and reloads the OS cache. The available privileged re-apply is audit-gated \u2014 it does nothing when the files are already correct, which is exactly the unloaded case \u2014 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 \u2014 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 \u2014 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 \u2014 8 parallel lookups against a real mapping \u2014 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** \u2014 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) \u2014 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 \u2014 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 \u2192 start) |\n| `caddy-ipv4-unreachable` | critical | Caddy's loaded config declares an HTTPS listener but `127.0.0.1:<port>` **refuses** connections \u2014 every IPv4 client is cut off (browsers, curl, and the pf 443\u21928443 redirect) while the process is up and its admin API answers | **Advisory \u2014 no auto-fix.** Run `supbuddy proxy restart` to rebind. Only a connection **refused** counts: a *timeout* on a pf redirect target is normal (the reply is reverse-NAT'd back to :443 and never matches your socket), so it is never reported as a fault |\n| `ca-not-trusted` | warning | The local CA exists but the **current** root isn't trusted (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 \u2014 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** \u2014 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 \u2014 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 \u2014 the cause of Firefox-family `SEC_ERROR_BAD_SIGNATURE` | Deletes the stale roots **and installs the current one** in a single elevated batch (asks for your password). Delete-only could leave a machine with no trusted Caddy root at all when the current one wasn't in the keychain yet |\n| `orphan-caddy-container` | warning | A leftover pre-binary-era `supbuddy-caddy` Docker container | Removes the container, its `supbuddy-net` network and its data/config volumes (the `caddy:latest` image is kept) |\n| `orphan-lo0-aliases` | warning | *macOS.* `127.0.0.N` aliases on `lo0` owned by no Thin project \u2014 deleting a Thin project never tore its alias down | Removes only those aliases (asks for your password); `127.0.0.1` and any non-Supbuddy alias are left alone |\n| `orphan-dind` | warning | Docker-in-Docker containers from the retired Isolated (VM) mode belonging to no registered project \u2014 each one confirmed to actually be a DinD first | Force-removes those containers and their `<name>-docker` data volumes. **This is project data**: if you deleted a project and chose to keep its data, this is that data. The Caddy container and non-Supbuddy containers are never touched |\n| `orphan-supabase-volumes` | warning | Docker volumes of Supbuddy-managed (`sb-`-prefixed) Supabase stacks owned by no registered project | Removes those volumes. **This is database data.** Host-mode stacks, stacks you started yourself, and projects still in the MCP trash (restorable for 7 days) are never touched |\n| `orphan-launchagents` | warning | *macOS.* Legacy CA-trust LaunchAgents from older builds that re-export `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` at every login and break **public** TLS | Boots each agent out and removes it, leaving a `.supbuddy-backup` copy alongside. Root-owned agents under `/Library` may resist; the fix reports those as a failure instead of claiming success |\n| `orphan-electron-token-files` | warning | Leftover `~/.config/Supbuddy/mcp/<clientId>.bin` token files from the retired Electron app, for clients that no longer exist | Deletes those files (no elevation). They can't be decrypted any more anyway; clients that are merely revoked keep their record and are left alone |\n| `orphan-mcp-secrets` | warning | `secrets/mcp-<clientId>.secret` files whose token can no longer authenticate (client revoked, or no record at all) | Deletes those files (no elevation) \u2014 it can't log a working agent out. Secrets for current clients, and the non-MCP secrets stored alongside them (license, cloud session, Tailscale key), are left untouched |\n| `unmanaged-supabase` | info | A Supabase stack on the host daemon that maps to no registered project (e.g. a plain `supabase start`) | **Advisory \u2014 no auto-fix.** Supbuddy never tears down a stack you started yourself; run `supabase stop` in its project if you don't need it |\n| `stale-resolver-files` | info | *macOS.* Supbuddy-marked `/etc/resolver/<suffix>` files for suffixes no **enabled** project or mapping claims any more (deleted projects, a disabled one, an older per-project TLD) | Removes only those files (asks for your password); suffixes still in use are left alone. Reversible \u2014 enabling the project or restarting the proxy writes the file back |\n| `pf-conf-backups` | info | *macOS.* `/etc/pf.conf.backup.<timestamp>` copies piled up in `/etc` by older versions (which wrote a new one on every port-forwarding disable) | Removes the redundant copies, **keeping the newest one** and the stable `/etc/pf.conf.supbuddy-backup` (asks for your password) |\n| `stale-mcp-config-tokens` | info | An agent config (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, or a registered project's `.mcp.json` / `.cursor/mcp.json`) holds a `mcpServers.supbuddy` token Supbuddy no longer accepts \u2014 the 401 \"Token not recognized\" state | **Advisory \u2014 no auto-fix.** Supbuddy won't rewrite config files you own and edit. Delete the `mcpServers.supbuddy` entry from the file named in the finding, or run `supbuddy mcp add <agent>` to mint a fresh token. The finding names the file, never the token |\n| `stale-browser-nss-roots` | info | *macOS.* A Firefox / Zen / LibreWolf / Waterfox profile whose own NSS store (`cert9.db`) holds a `Caddy Local Authority` root Supbuddy can't reach | **Advisory \u2014 no auto-fix.** Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings \u2192 Privacy & Security \u2192 Certificates \u2192 View Certificates\u2026 \u2192 Authorities, delete every `Caddy Local Authority` entry, then re-import Supbuddy's CA |\n\nThe same scan and repairs are available over MCP as the `doctor` and `doctor_fix` tools (see *MCP tool surface*), and in the app under **Settings \u2192 General \u2192 System health \u2192 Scan** \u2014 the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see *Settings reference \u2192 General*). The panel has no reset button: a wipe stays a CLI operation.\n\n### System reset\n\n```bash\nsupbuddy reset # soft (the default): app state + caches\nsupbuddy reset --tier=deep # + services, Caddy containers, system integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\nsupbuddy reset --tier=deep --yes # skip the y/N confirm (scripting / CI)\nsupbuddy reset --tier=full --yes --i-understand # the ONLY scripted path for a full reset\n```\n\n`supbuddy reset` removes Supbuddy's footprint from your machine in **tiers**, and each tier is a superset of the one before it:\n\n| Tier | What it removes |\n| --- | --- |\n| `soft` (default) | App state \u2014 projects, mappings, settings, MCP clients, project-context sync and user-skill records \u2014 plus the Docker image cache (`<app-data>/image-cache`, images are re-pulled on demand) and the buffered request log. It touches **no** Docker container or volume, **nothing** under `/etc`, and **no** file in your repos, so it never asks for your password |\n| `deep` | \u2026plus: stops every service; removes the leftover Caddy container/network/volumes, the `/etc/hosts` entries, the `/etc/resolver` files, the pf `:80`/`:443` redirect, the `127.0.0.N` loopback aliases, the bundled-runtime CA trust and the `Caddy Local Authority` roots in your keychain, and the token files of already-revoked MCP clients. **Your data is preserved**: no Supabase volume, no DinD container, no repo file and no *live* MCP token is touched \u2014 `deep` unwinds what Supbuddy installed on the machine, it is not a data wipe |\n| `full` | \u2026plus **your project data, backed up first**: every Supbuddy-**managed** (`sb-`-prefixed) Supabase stack's data volumes and every DinD container with its data volume, the `.supbuddy/` directories, managed blocks and `.env.supbuddy` files in your registered repos, and **every** credential (license, live MCP tokens, cloud session, Tailscale key) \u2014 then it uninstalls the start-on-login service and empties the app-data directory. A **host-mode** project's Supabase stack is only *stopped*: those containers and volumes are yours, and they are kept |\n\nMost steps enumerate what's actually on your machine first, so anything that isn't there drops out of the manifest instead of being advertised and skipped. `soft` needs no elevated access at all. `deep` batches the pf redirect, the resolver configuration and the loopback aliases into **one** password prompt; the legacy `/etc/hosts` block and the keychain CA removal ask separately, so expect up to three. `full` may prompt more than once as it tears projects down.\n\n**Reset is a CLI operation, on purpose \u2014 there is no reset button in the app.** The gates that make a wipe safe don't survive the trip into a GUI: a typed `RESET`, a refusal on non-interactive input, and a daemon confirmation the app itself would be answering. On top of that, `--tier=full` refuses outright while the desktop app is running (its watchdog respawns the daemon ~20s after it stops), so a button for it would be a trap. The app's **Settings \u2192 General \u2192 System health** panel points here instead.\n\n**Backup before harm.** Anything you can't regenerate \u2014 `state.json`, every managed Supabase database that is running (`pg_dump`, custom format, with a `.sha256` alongside), every managed data volume (`tar.gz`, verified with `gzip -t`) \u2014 is written to `<app-data>/backups/reset-<timestamp>/` **before** a single destructive step runs, and if any backup fails the whole reset **aborts before destroying anything**. The directory is printed prominently before you confirm, and again when the reset finishes; `manifest.json` inside it records exactly what was planned and what ran. On top of that coarse guarantee, each volume is gated individually: **no archive, no removal** \u2014 a volume with no non-empty `.tar.gz` next to it is left alone and the run records why.\n\n**A backup that can't be written stops the reset \u2014 safely.** Archiving a volume is given ten minutes; a genuinely large one (tens of GB of Postgres data plus a DinD image cache) can exceed that, and when it does the reset **aborts with nothing destroyed**. Stop the stack and prune what you don't need (`docker system prune`, drop old branches/schemas), or archive that volume yourself, then run the reset again. The same applies to any other backup failure: a full disk, an unreadable volume, a Docker daemon that stops answering.\n\n**The backups survive a full reset.** They live inside the app-data directory, so the last step of `--tier=full` empties that directory *content-wise and skips `backups/`* rather than deleting it wholesale. Move that directory somewhere safe afterwards \u2014 it's the only copy.\n\n**Confirmation.** Every tier prints the **manifest** first \u2014 the literal list of actions that will run, derived from the same actions the engine executes. `soft` and `deep` then ask `Apply this \"<tier>\" reset? [y/N]` (default **No**); `--yes` skips that prompt. `--tier=full` requires you to **type the word `RESET`** \u2014 `--yes` alone does **not** bypass it. The one scripted path for a full reset is `--yes --i-understand`, both flags together. Every prompt refuses on a non-interactive (piped) stdin rather than proceeding.\n\n**The daemon confirms too.** `soft` and `deep` run inside the daemon, which asks for its own approval before it starts \u2014 the same gate as `doctor --fix` and `ca uninstall`. With the Supbuddy app open you get a native **Allow / Deny** dialog. A daemon with neither a dialog nor a terminal \u2014 the start-on-login service, or an app-spawned daemon while the app is closed \u2014 has nobody to ask and **denies**; run a foreground `supbuddy daemon` in one terminal and the reset from a second, and it will prompt there. Don't reach for `supbuddy daemon --yes` to get past it: that auto-approves *every* confirmation for that daemon's whole lifetime.\n\n**Quit the app before a full reset.** The desktop app supervises the daemon and restarts it about 20 seconds after it stops, which would put a live daemon back into the directory the last step clears. `--tier=full` refuses up front while the app is running \u2014 before it asks you to type `RESET`, and before it changes anything. Quit the app (menu bar icon \u2192 Quit) and run it again; the quit dialog's default **Leave running** is fine, since the reset stops the daemon itself. The check looks for the *app* process only, so nothing else has to change. `--tier=full` also runs with no daemon at all, so if you quit with **Stop service** you can go straight ahead.\n\n**The order of a full reset**, once you've confirmed: the start-on-login service is uninstalled, the daemon is stopped and waited for (the reset refuses to run against a live daemon, which would rewrite `state.json` underneath it), the backup and teardown steps above run, and only then is the app-data directory emptied \u2014 keeping `backups/`. If the reset aborted, or if a daemon came back while it was running, the app-data directory is left in place and the CLI tells you so rather than clearing it under a live process.\n\n`soft` and `deep` are also available over MCP as the plan-gated `system_wipe` tool (see *MCP tool surface*). `--tier=full` is **CLI-only**: it deletes the credentials any agent would be calling with, and a daemon cannot uninstall the service it runs under or delete the directory it runs from.\n\n**What a full reset does not remove.** It only ever touches paths of **registered** projects \u2014 there is no disk scan for stray `.supbuddy` directories \u2014 and it won't delete or rewrite files whose ownership is ambiguous. So after `--tier=full` these are still on disk, and you can remove them by hand:\n\n- Per-editor rule files Supbuddy wrote in your repos: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.idea/supbuddy.md`. Shared files (`CLAUDE.md`, `AGENTS.md`, `.gitignore`, \u2026) keep their content and only lose Supbuddy's sentinel-delimited block.\n- Values `apply_env` merged into your **own** `.env*` files. The fully-owned `.env.supbuddy` files *are* deleted.\n- The bare `.env.supbuddy` line in `.gitignore` \u2014 it sits outside the managed block.\n- `vite.config.*` `allowedHosts` and `next.config.*` dev-origin patches.\n- `supabase/config.toml` port / `project_id` patches, when restoring the original file failed during the Thin teardown.\n- MCP client config entries written by `mcp add` / `install_mcp_config` (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, a project `.mcp.json` / `.cursor/mcp.json`). The token they hold is dead the moment the secrets are deleted; `supbuddy doctor`'s `stale-mcp-config-tokens` check will name each file.\n- The `caddy:latest` Docker image (shared and re-pullable) and anything a host-mode project owns.\n- The Supbuddy app itself \u2014 drag `Supbuddy.app` to the Trash \u2014 and the backups directory, which is the whole point of keeping it.\n\n## Settings reference\n\nOpen Settings via the gear icon top-right or by clicking the tray icon \u2192 Open Dashboard \u2192 gear. Five tabs.\n\n### General\n\n- **Theme**: dark or light.\n- **Auto-start at login**: registers Supbuddy as a macOS login item. Default: on.\n- **Default TLD**: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default: `test`.\n- **Default isolation**: `host` or `thin` for newly added projects. Default: `thin` (per-project loopback IP; apps keep canonical ports like `:3000`). MCP registration additionally keeps a project on `host` when its Supabase stack is already running on the host outside Supbuddy.\n- **Auto-subdomain mapping**: when on, services and apps detected during a project scan get mappings created automatically. Default: on.\n- **Bundled-runtime trust**: installs Supbuddy's local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, \u2026) actually read. These apps don't consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to `*.test` with `unable to get local issuer certificate`. Default: prompted on first launch when one of those tools is detected.\n - **macOS**: writes `~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plist` and calls `launchctl setenv NODE_EXTRA_CA_CERTS` 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* \u2014 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 \u2014 **not** Caddy's own `caddy-data/\u2026/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 \u2014 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 \u2014 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 \u2014 an older build's value, or Caddy's `root.crt` from a hand-rolled setup \u2014 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 \u2014 the same read-only, 17-check health & drift scan as `supbuddy doctor` (see *System doctor*), in the app. Opening the panel only scans; it changes nothing.\n - Findings are grouped **critical \u2192 warning \u2192 info**, each with its title, one-line detail, concrete evidence (paths, container names, fingerprints), check id and category. **Rescan** re-runs the scan; the header shows the counts. A scan that times out says so and points at `supbuddy doctor` \u2014 the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels.\n - **Fix\u2026** on a fixable finding \u2014 or **Fix all (n)** in the header \u2014 never repairs anything by itself. It opens the **manifest**: the literal list of actions that would run, each marked *destructive* or *safe*, built from the same actions the engine executes. **Apply** stays disabled until that manifest has loaded and contains at least one action, so an empty or failed plan can't be rubber-stamped. Same confirm-before-harm contract as `doctor --fix`.\n - Repairs that need elevated access ask for your password when they run. One that outlives the app's 15-second reply window (a password prompt sitting open) is reported as *may still be running \u2014 rescan in a moment*, not as a failure.\n - Findings with no auto-fix show **advisory** instead of a Fix button; the detail says what to do by hand. Checks that couldn't run at all are listed at the bottom as *Checks that could not run*, rather than being silently dropped.\n - **There is no reset button here, on purpose** \u2014 the footer points at `supbuddy reset` instead. See *System reset*.\n\n### Network\n\n- **HTTP port**: default 8080.\n- **HTTPS port**: default 8443.\n- **DNS port**: default 5353.\n- **Port forwarding**: when on, inserts a `pfctl` rule mapping 80\u2192HTTP port and 443\u2192HTTPS port into `/etc/pf.conf` (correct translation-section placement; self-heals a file corrupted by older versions). Asks for sudo once. Status reflects a live 443 enforcement probe, not just file presence.\n- **LAN sharing**: binds Caddy to `0.0.0.0` + starts mDNS responder.\n- **Tailscale**: paste a tailnet API key to enable split-DNS push.\n- **Install / Uninstall CA**: **Install** adds Caddy's root cert to your System keychain (removing any stale same-name roots first), 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 **\u22EF** actions menu: install, edit scopes, set-primary, rotate token, revoke.\n- **Activity**: audit log with Apply/Cancel/Undo on plan rows.\n- **Trash**: soft-deleted mappings and projects, restorable for 7 days.\n- Settings: server `enabled`, `port` (default 9877), `audit_cap` (default 5000), `trash_ttl_days` (default 7).\n\n### AI Skills\n\nInstall Supbuddy's agent **skill at the user level** (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a **master on/off** plus an **autosync** toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.\n\n- **Who can install at user level**: only agents whose global file Supbuddy fully **owns** and that **self-scope** (act only when the working directory has a `.supbuddy/`): **Claude Code** (`~/.claude/skills/supbuddy/SKILL.md`) and **Cursor** (`~/.cursor/skills/supbuddy/SKILL.md`). The install is reference-counted under a synthetic `__user__` ref so it persists independent of any project and is never pruned by the boot reconcile.\n- **Master \u2194 project**: the AI Skills tab is the **master** (user-level). To commit a skill into a specific repo, use that project's **AI Tools** tab and set the target to **Project** (the old `local` scope, which writes into the repo for teammates); **User** there means the master install covers it.\n- Agents whose global file holds *your own* content (Claude `CLAUDE.md`, Codex `AGENTS.md`, Copilot, Windsurf, Continue, JetBrains) are **project-level only**: a machine-wide write there could clobber your config, so they're injected per-project instead.\n\n## Tray menu\n\nThe macOS menu bar tray icon opens a menu with:\n\n- **Status: \u2026**: current proxy state (running / idle).\n- **DNS Active (:5353)**: shown when proxy is running.\n- **LAN Sharing (\\<ip\\>)**: shown when LAN sharing is on.\n- **Tailscale (\\<ip\\>)**: shown when Tailscale is connected.\n- **Start Proxy / Stop Proxy**: opens the dashboard.\n- **Projects**: each project opens a submenu with **Apps** (click to open the mapped URL), **Supabase** services (status dot + open), and **Scripts** (your bookmarked scripts as a one-click **Start <name>** / **Stop <name>** toggle), plus **Restart Supabase**/**Restart services** and **Show in Supbuddy**.\n- **Open Dashboard**.\n- **Sync AI context for all projects**: runs the project-context sync engine for every registered project (writes `.supbuddy/`, `CLAUDE.md`, `AGENTS.md`, etc.).\n- **Show Logs**: reveals `main.log` in Finder.\n- **Check for Updates...**: manual update check (only enabled in packaged builds). The panel names all three moving parts and their versions \u2014 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 \u2014 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 \u2014 an unreadable `state.json`, an untrusted CA, a wedged Caddy, port 443 not redirecting, stale duplicate CA roots, legacy CA-trust LaunchAgents poisoning public TLS, an agent config still holding a revoked MCP token, a Firefox profile pinning an old Caddy root, and leftovers from deleted projects (Docker containers/volumes, `127.0.0.N` loopback aliases, `/etc/resolver` files, MCP token files). Add `--fix` to apply the opt-in repairs after a confirmation prompt \u2014 some checks are advisory and have no auto-fix. See [System doctor](#system-doctor) for the full check list and flags.\n\n### Browser shows \"Not secure\" or certificate warning\n\nThe Caddy CA is not trusted. Open **Settings \u2192 Network \u2192 Install Certificate**. macOS will prompt for your password \u2014 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* \u2192 **System** keychain, then the **login** keychain \u2192 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 \u2192 General \u2192 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 \u2014 `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/\u2026/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 \u2192 **Clean up leftovers\u2026** to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to `\u2026/Supbuddy/backups/<project>-<timestamp>/`). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.\n\nIf the leftover copy's data looks **newer** than the active one, the warning turns red; don't delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn't manage at all (e.g. a plain `supabase start`).\n\n### MCP client says \"Invalid OAuth error\" or \"JSON Parse error: Unexpected EOF\"\n\nThe MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in **Settings \u2192 MCP \u2192 the client's \u22EF menu \u2192 Rotate token**) or you're on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include `WWW-Authenticate: Bearer` so the client doesn't fall back to OAuth.\n\n### MCP token disappeared after app restart\n\nFixed in recent builds. If you're on an older version, regenerate the token. Root cause was that `addMcpClient` didn't trigger state persistence; the client was held in memory only.\n\n### Server Actions return 403 in a Next.js app behind Supbuddy\n\nNext.js's CSRF guard rejects POSTs whose Origin isn't in `experimental.serverActions.allowedOrigins`. Supbuddy detects this and flags it in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply\u2026** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\nOn **Next.js 15.3+/16**, a proxied dev request can also be blocked (e.g. a \"Cross origin request detected\" warning) because Supbuddy now passes the real browser `Origin` through rather than rewriting it, and Next validates it against `allowedDevOrigins` (which defaults to `localhost`). Add your Supbuddy domain to `allowedDevOrigins` in `next.config` \u2014 see [Next.js cross-origin dev requests](#nextjs-cross-origin-dev-requests-alloweddevorigins). This is a separate key from the Server Actions list; 15.3+/16 may need both.\n\n### Vite dev server returns \"Blocked request. This host is not allowed.\" (403)\n\nVite (v5+) rejects requests whose `Host` header isn't in `server.allowedHosts`, so a Vite app reached through a Supbuddy domain 403s until the host is allowed. Supbuddy detects this and flags `vite: N hosts blocked` in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply\u2026** to preview a diff and write `server.allowedHosts` into your `vite.config` directly. **Restart the Vite dev server afterward**; Vite does not hot-reload its config. A single `.your-project.local` entry covers every subdomain.\n\n### Supabase Realtime: channel reaches `SUBSCRIBED` but no `postgres_changes` events arrive\n\nIf a channel subscribes fine (and writes succeed) but change events never fire, this is almost always **realtime warmup timing right after the stack starts** \u2014 not the Supbuddy proxy. Local Realtime can accept a channel join and report `SUBSCRIBED` before its logical-replication binding for the tenant is ready, so `INSERT`/`UPDATE`s in that brief window are silently missed. Give the stack a few seconds after the Supabase tab goes green, then re-subscribe (or reconnect the channel). This is **unrelated to the `.local` domain**: Kong routes `/realtime/v1/*` by path and rewrites the upstream `Host` to its internal realtime tenant, so reaching realtime through `https://api.<project>.local` behaves identically to the raw `localhost:54321` port \u2014 forwarding the `.local` host upstream does not change tenant resolution. The new `sb_publishable_*` / `sb_secret_*` API keys also work for local realtime (Kong maps them to the legacy JWT), so you don't need to switch key formats.\n\n### Project shows a red \"PROXY ERROR\" banner: domain resolves but won't load\n\nAfter the proxy starts, Supbuddy runs an end-to-end reachability check: it resolves a project domain through the OS resolver and tries to connect to Caddy on the HTTPS port. If the name resolves but the connection fails, the project shows a red **PROXY ERROR** banner naming the likely cause (DNS, port-forwarding, or mDNS race) plus a recovery action.\n\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` \u2014 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 \u2014 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 \u2014 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 \u2014 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\u21928443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click **Retry**; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off\u2192on instead. If LAN sharing is **off**, disregard any \"LAN sharing / Bonjour\" wording in the banner; the cause is the missing forward, not mDNS.\n\n**If the name doesn't resolve at all and the project is on `.local`**, the banner now says so. macOS reserves `.local` for Bonjour/mDNS (RFC 6762), and mDNS is consulted by a path an `/etc/resolver/<suffix>` file does not govern \u2014 so on Sonoma and later 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 \u2192 TLD**) or with `supbuddy project set <project> --tld=test`. (This is *not* the `.local` slowness of 3.5.17 and earlier \u2014 that was our own DNS server and it is fixed. This is resolution failing outright, which the suffix genuinely can cause.)\n\n**Automatic repairs stop re-prompting.** When the proxy is running but unreachable, Supbuddy re-runs the privileged setup to recover it \u2014 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 \u2014 **Retry** in the app, `supbuddy proxy restart`, the MCP `start_proxy` \u2014 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 \u2014 the rule being on disk isn't enough. If the rule is present but not being enforced (typically right after a reboot, or when an older Supbuddy version left `/etc/pf.conf` in a broken state), the status carries a `pf_not_enforcing` diagnostic instead of a false \"enabled\", and the banner tells you to **restart the proxy** to re-apply the redirect.\n\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 \u2014 whose Caddyfile has no site blocks, leaving nothing bound to 8443 \u2014 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 \u2014 which pf rejects, because translation rules must come before filtering rules. That silently invalidated the whole ruleset, so every later `pfctl -f` failed and 443 was dead. Current builds insert the anchor in the correct translation section and **self-heal** a file corrupted by the old version on the next proxy start. Supbuddy keeps a single stable backup at `/etc/pf.conf.supbuddy-backup` (older builds accumulated unbounded timestamped backups). If a restart doesn't fix it, inspect `/etc/pf.conf` and confirm the `rdr-anchor \"virtual.localhost\"` line sits before `anchor \"com.apple/*\"`.\n\n### Proxy came up but shows a degraded \"error\" state\n\nIf the one-time sudo prompt for port forwarding / DNS is cancelled or fails, Supbuddy no longer aborts the whole start. Caddy still starts and HTTPS keeps working on the high port (8443), and the CA is still generated; the proxy just shows an actionable **error** (degraded) state with a **Retry**. Click **Retry** and approve the sudo prompt to restore real-port (80/443) access and DNS. Until then, reach your apps on `https://<domain>:8443`.\n\n### Port already in use (8080, 8443, 5353, 9877)\n\nDefault ports: HTTP 8080, HTTPS 8443, DNS 5353, MCP 9877. Change them in **Settings \u2192 Network** / **Settings \u2192 MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nUse `supbuddy reset` (see *System reset*) \u2014 it backs up anything you can't regenerate first, and it removes the things a plain `rm -rf` leaves behind (the pf redirect, the resolver files, the loopback aliases, the trusted CA):\n\n```bash\nsupbuddy reset --tier=soft # just the app state and caches\nsupbuddy reset --tier=deep # + services, Caddy leftovers, /etc integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\n```\n\nThe manual equivalent, if the CLI isn't available \u2014 quit Supbuddy first, and note that this deletes `secrets/` and any backups under it with no copy anywhere:\n\n```bash\n# Wipe app data (state, certs, Caddyfile, logs, MCP tokens under secrets/)\nrm -rf ~/Library/Application\\ Support/Supbuddy\n\n# Optional: remove the trusted CA\nsudo security delete-certificate -c \"Caddy Local Authority\" /Library/Keychains/System.keychain\n```\n\n## FAQ\n\n### Is Supbuddy free?\n\nYes. Supbuddy is free. Register as many projects and mappings as you want, with full HTTPS, full DNS, full Supabase isolation, and full read and write MCP access. There are no caps and no tiers.\n\n### Does Supbuddy send my data anywhere?\n\nNo. Caddy, the DNS server, and the MCP server all run locally on your Mac. The only outbound traffic is: Tailscale split-DNS push (only if you enabled it), auto-update checks (GitHub Releases), and Google Analytics on the marketing site (not the desktop app). The desktop app does not send telemetry.\n\n### Can I work offline?\n\nYes. The app works fully offline once the CA is trusted and projects are registered.\n\n### Linux / Windows support?\n\nThe desktop app is macOS-only in v2. The headless CLI and daemon also run on Linux, where `supbuddy service install` registers a `systemd-user` start-on-login unit (macOS uses `launchd`). Windows is not supported. A few desktop code paths (certutil, update-ca-certificates) anticipate other platforms but are not tested there.\n\n### Can I use my own TLD?\n\nYes. Set any TLD in **Settings \u2192 General \u2192 Default TLD**. Supbuddy installs `/etc/resolver/<project-domain>` files that tell macOS to query our DNS server for that project's domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.\n\n### What happens if I delete a project?\n\nThe project moves to the Trash (visible in **Settings \u2192 MCP \u2192 Trash**) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.\n\n### How do I uninstall Supbuddy?\n\n1. Quit the app (the full reset refuses to run while it's open, because its watchdog restarts the daemon).\n2. Run `supbuddy reset --tier=full` and type `RESET` when it asks. This backs up your project data, then removes the containers, volumes, `/etc` integrations, CA trust, repo artifacts, credentials, the start-on-login service and the app-data directory \u2014 keeping `<app-data>/backups/reset-<timestamp>/`. See *System reset*, including the short list of things it deliberately leaves behind.\n3. Drag **Supbuddy.app** from `/Applications` to the Trash, and move the backups directory somewhere safe (or delete it).\n4. If you'd rather not use the CLI: see \"Wipe everything and start over\" above for the manual equivalent, plus `sudo security delete-certificate -c \"Caddy Local Authority\" /Library/Keychains/System.keychain` to remove the trusted CA.\n\n### Where do I report a bug?\n\nEmail support with your version (visible at the bottom of the Settings popover) and the relevant lines from `~/Library/Application Support/Supbuddy/main.log`.\n";
|
|
40308
|
+
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 \u2014 **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 \u2192 Network** later). Supbuddy adds the CA (Caddy's internal PKI at `~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt`) to your **System keychain** via `sudo security add-trusted-cert`; macOS asks for your password once. (On macOS 15+ that system-wide step is no longer permitted to a background helper, so Install falls back to per-user trust \u2014 see below.) Caddy does **not** self-install trust (the generated Caddyfile sets `skip_install_trust`), so this button is what makes the padlock green \u2014 fully quit and reopen your browser afterward to pick it up. Every Supbuddy domain then gets HTTPS with no per-domain prompts or warnings. (On Windows the install is manual: Supbuddy shows the PowerShell `Import-Certificate \u2026 -CertStoreLocation Cert:\\LocalMachine\\Root` command to run as Administrator.)\n\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 \u2014 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\"* \u2014 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 \u2014 **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 \u2014 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 \u2014 on Firefox/Zen \u2014 set `security.enterprise_roots.enabled` to `true` in `about:config` so the browser reads the System keychain.\n\nIf Supbuddy detects an AI tool that ships its own JavaScript runtime (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, etc.) it will also offer to enable **Bundled-runtime trust** in the same first-run prompt. Those tools don't read the system Keychain (they carry their own Mozilla CA bundle), so without this setup the first OAuth/MCP connection to a `*.test` URL fails with `unable to get local issuer certificate`. Enable it once and Supbuddy keeps it in sync (including across yearly Caddy CA rotation). See the **Bundled-runtime trust** section under Settings \u2192 General for details.\n\nIf you skip the prompt, you can re-trigger it any time from the **Settings \u2192 Network** tab.\n\n### 3. Add your first project\n\nClick **Add project** in the Configure tab and pick a project root folder (the one with `package.json` and/or `supabase/config.toml`). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:\n\n- Supabase Kong \u2192 `api.<project>.test`\n- Supabase Studio \u2192 `studio.<project>.test`\n- Supabase Inbucket / Mailpit \u2192 `mail.<project>.test`\n- Each detected app (Next.js, Vite, etc.) \u2192 `<app-name>.<project>.test`\n\nThe default TLD is `.test`. You can change it project-wide in **Settings \u2192 General \u2192 Default TLD**.\n\n> **`.local` is fine again, from 3.5.18.** Earlier versions made every managed domain resolve slowly \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2192 Network**. Supbuddy inserts a `pfctl` redirect rule into `/etc/pf.conf` (asks for sudo once) and reports whether the redirect is actually being enforced via a live 443 probe \u2014 not merely that the rule is on disk. If port forwarding is on but 443 won't connect, see [Port forwarding is on but 443 won't connect](#port-forwarding-is-on-but-443-wont-connect).\n\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 \u2014 most often when no mapping is enabled yet, which generates a Caddyfile with no site blocks \u2014 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 \u2192 port pair (e.g. `api.acme.test \u2192 54321`). Auto-generated mappings are tied to a detected service or app; you can also create manual ones.\n- **Isolation mode**: per-project. One of:\n - `thin` (lightweight, **the default for newly registered projects**): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own **port block** and a unique Compose `project_id`, written into that project's `supabase/config.toml`. That's what lets several Supabase projects run **at once on the shared daemon**, each reached by name (`api.<project>.test`, `studio.<project>.test`). Apps bind a **per-project loopback IP** (127.0.0.2, 127.0.0.3, \u2026) so every project's dev servers keep their canonical ports \u2014 each project gets its *own* `:3000`. Start dev servers with `supbuddy run -- <dev command>` so they bind that IP. Supbuddy owns those config.toml keys while the project is `thin` and restores them the moment you switch back to `host`.\n - `host`: everything shares `127.0.0.1` and the stock ports. Dev-server ports collide across projects, and only one host-mode Supabase project can run at a time (the standard `supabase start` constraint). Use `host` **only when the project's Supabase stack is already running on the host independently of Supbuddy** (you run `supabase start` yourself and don't want Supbuddy re-porting `config.toml`). MCP registration (`register_project`) detects that case and keeps such projects on `host` automatically; in the app's Add-project dialog, pick **Host** in the Environment section yourself.\n- **Active vs inactive**: any project can be \"active\" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.\n\n## Project cards (Configure tab)\n\nEach registered project appears as a card in the Configure tab. Cards have a single-row header that's always visible and a tab-based body that expands on click.\n\n### Header\n\nReading left to right:\n\n- **Expand chevron** + **project name**: click to expand/collapse the card.\n- **Status indicator**: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem's state.\n- **Tech badges**: e.g. `TurboRepo`, `Supabase` (shown when detected).\n\n**Supabase connection warning.** When a project's app `.env` is missing the\nSupabase connection vars, or they've gone stale relative to the live target\n(e.g. after switching isolation, which republishes ports), the card shows a\n`supabase env: not connected` / `supabase env: out of date` pill. Click it to\nopen Connect and push fresh values, or choose **Ignore for this project**.\n- **Env mode chip**: read-only `Host` or `Thin` label (matching the project's isolation mode). To switch modes, open the **Supabase** tab and use the **Environment** section at the top.\n- **Issues counter**: red for errors, amber for warnings. Click to open the **issues popover** (see below). Hidden when there are no issues.\n- **Warnings chip**: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.\n- **Enable toggle** (right edge): turn the project's proxy on/off without deleting it.\n- **\u22EF actions menu** (right edge): every project-level action: **Edit project**, **Rescan**, **Re-check configs** (re-runs the connection/env drift check for this project), **Select folder**, **Export bundle**, and **Delete project**.\n\n### Issues popover\n\nClicking the issues counter opens a popover listing all current errors and warnings. Each issue shows a severity icon, title, optional detail, and a **\u2192 open {tab}** link. Clicking the link jumps to the relevant tab and closes the popover.\n\n### Body tabs (when expanded)\n\nThe body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header's **\u22EF menu**, not a tab.)\n\n#### Apps (default tab)\n\nPer-app rows are domain-first: `domain \u2192 :port` (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed **edit** / **delete** / **access** (LAN / Tailscale state) actions and the per-mapping **toggle** to the right edge. A **Map** CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.\n\n#### Supabase (shown when Supabase is detected)\n\n**Environment section (top):** host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see [Migrating a legacy Isolated (VM) project to Thin](#migrating-a-legacy-isolated-vm-project-to-thin)).\n\n**Action bar:** Start, Stop, Restart buttons; a first-class **Connect** button (cyan, opens the connection panel for `.env` generation / merge); and a **More** menu with **Config editor** and **Details**.\n\n**Config editor: secret extraction.** When you save a `supabase/config.toml` that contains a secret-bearing value inline (e.g. an SMTP password under `[auth.email.smtp]`, an OAuth `secret`, or any `*_key`/`auth_token`), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root `.env.local`). The value is written there and replaced in `config.toml` with an `env(SUPABASE_\u2026)` reference, so secrets never land in git. Supbuddy injects those `SUPABASE_`-prefixed values back into the `supabase start` environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)\n\n**Service rows** (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.\n\n#### Compose (shown when Compose services are detected)\n\n**Action bar:** Start, Stop, Restart. **Service rows** are read-only (status dot, name, URL). Add-on services declared in `supbuddy.addons.yml` (see **Add-on Compose services**) appear here alongside the base stack and in `get_compose_status` over MCP.\n\n#### Other (shown when non-Supabase, non-Compose services are detected)\n\nRead-only service rows: status dot, name, URL.\n\n#### Scripts (shown when scripts are detected)\n\nBookmarked scripts appear in a **Quick Access** group at the top; remaining scripts appear under **Other Scripts**. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.\n\n#### AI Tools\n\nWraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target **scope** (global / local), advanced options, and recent activity. See [Per-project AI context sync](#per-project-ai-context-sync) for what global vs. local means.\n\n> Project-level actions (**Edit**, **Rescan**, **Re-check configs**, **Select folder**, **Export bundle**, **Delete**) are no longer a tab. They live in the header's **\u22EF actions menu**.\n\n---\n\n## Multiple Supabase projects (the main use case)\n\nThe reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 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 \u2192 **Environment** section:\n\n### Thin (lightweight, recommended)\n\nSwitch a project to **Thin**. Supbuddy assigns it a free port block (in the `55000+` range), writes those ports plus a unique Compose `project_id` into its `supabase/config.toml`, and runs `supabase start` on your **normal host Docker**, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (`api.acme.test`, `studio.acme.test`, `mail.acme.test`). Switch back to **Host** and Supbuddy restores the original `config.toml` and stops just that project's stack.\n\nThis is the lightest, fastest option and the right default for most setups \u2014 which is why **newly registered projects default to Thin**. One caveat: if your `config.toml` omits a port key (e.g. 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\u201354329` block, Supbuddy reports that service's port as **unknown** rather than substituting the stock one \u2014 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 \u2014 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, \u2026, persisted per project). That loopback covers **app dev servers only** \u2014 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 \u2014 five Next.js apps in five projects can all run on `:3000` at once, and Supbuddy's proxy routes each `web.<project>.test` to its project's IP.\n\nStart dev servers through the launcher:\n\n```bash\nsupbuddy run -- next dev # binds -H <project loopback IP>, stays on :3000\nsupbuddy run -- vite # injects --host <ip> --strictPort\nsupbuddy run --print -- next dev # show what would run, without running it\n```\n\n`supbuddy run` reads the project's IP from the nearest `.supbuddy/meta.json` (`loopbackIp`, written when Thin is enabled), ensures the loopback alias exists, injects the right bind flag for the detected framework, and execs your command. It prints one concise line with the project's Caddy-proxied URL (e.g. `[supbuddy] \u2192 https://web.<project>.test`) \u2014 the address you should actually open. For **Next and Vite** it also hides the dev server's own `- Local:/- Network:` banner (which only echoes the raw loopback IP `127.0.0.N:<port>`, bypassing Supbuddy's HTTPS proxy): those two lines are filtered out of the piped output, every other line passes through untouched, and colours are preserved via `FORCE_COLOR` (stdin stays interactive). Other frameworks pass through with no filtering. When a project has several app mappings, it matches the one whose port equals the dev server's port (from `--port`/`-p` or the framework default), else lists them all. Make it the project's `dev` script (`\"dev\": \"supbuddy run -- next dev\"`) so nobody \u2014 humans or agents \u2014 has to remember it. **Never move an app to a nonstandard port because `127.0.0.1:3000` is busy**; that port belongs to another project's IP space.\n\n### When to stay on Host\n\nKeep a project on **Host** only when its Supabase stack runs on the host *independently of Supbuddy* \u2014 you run `supabase start` yourself on the stock ports and don't want Supbuddy rewriting `config.toml`. MCP registration (`register_project`) detects a stack like that (running containers for the project's `config.toml` `project_id`) and keeps the project on Host automatically; in the app's Add-project dialog, pick **Host** in the Environment section for such projects. Stop the stack (`supabase stop`) and switch to Thin whenever you're ready.\n\n### Running them all at once\n\nRegister as many projects as you want, and all of them can be \"active\" (proxied) at the same time. There's no limit. A Thin project's stack restarts in seconds; a Host project needs the standard `supabase start` cycle.\n\n### Migrating a legacy Isolated (VM) project to Thin\n\nIf you created a project in an older version of Supbuddy that used the now-retired **Isolated (VM)** mode, Supbuddy detects it on launch and offers a one-way, guided migration to **Thin**. The migration wizard appears in the **Supabase** tab's Environment section for any project still flagged as VM.\n\nThe migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there's no way to switch back (but your data is intact in the Thin stack).\n\nOver MCP, three tools handle the migration bridge:\n\n- `list_pending_vm_migrations` (read): lists all projects still on the legacy VM mode awaiting migration.\n- `migrate_vm_to_thin` ( `{ project_id }` ) (write): starts the guided data-safe migration (dump, restore, verify).\n- `finish_vm_migration` ( `{ project_id }` ) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.\n\n## Custom domains & TLDs\n\nEvery mapping resolves through Supbuddy's built-in DNS server on port 5353. By default the TLD is `.test` (an IETF-reserved TLD safe for local use). You can change the default in **Settings \u2192 General \u2192 Default TLD** to `local`, `dev`, or anything else; existing mappings are migrated to the new TLD on save.\n\nFor host resolution, Supbuddy *does not* use `/etc/hosts` for wildcards; it runs a DNS resolver. macOS's default resolver only queries port 53; Supbuddy installs a per-project resolver file under `/etc/resolver/<project-domain>` (e.g. `/etc/resolver/myapp.local`) pointing at `127.0.0.1:5353`. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like `.local` (which Bonjour/mDNS owns). You'll be prompted for sudo the first time this changes.\n\nResolver files exist only for domains the proxy actually serves \u2014 the same set that gets a Caddy site block: enabled mappings that are either standalone or under an **enabled** project. Disable or delete a project and its resolver file is removed with its routes (one sudo prompt, and only when something really changed), so its domains go back to failing as \"server not found\" instead of resolving into a TLS handshake error from a proxy that has nothing to serve. Enabling it again writes the file back; so does restarting the proxy.\n\n### 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 \u2014 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 \u2014 confirmed, not on a single miss \u2014 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 \u2014 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 \u2014 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` \u2014 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 \u2014 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 \u2192 Default TLD, e.g. `.test`). A single project can opt into its **own** TLD \u2014 set the suffix in the project dialog, pass `tld` to the `register_project` / `update_project` MCP tools, or use the CLI: `supbuddy project add <path> --tld=portal` when registering, or `supbuddy project set <project> --tld=portal` on an existing one (`--tld=` with an empty value clears the override). That project's base domain and all its subdomains then live on the override TLD (e.g. `cueplusplus.portal`, `web.cueplusplus.portal`) while every other project stays on the global default. The override is durable across restarts and is unaffected when you change the global TLD. Prefer `.test` or a vanity label like `.portal`; avoid `.local` (it collides with macOS mDNS/Bonjour).\n\n### LAN sharing\n\nWhen LAN sharing is enabled (Settings \u2192 Network), Supbuddy binds Caddy to `0.0.0.0` instead of `127.0.0.1` and runs an mDNS responder so other machines on your local network can reach your dev servers via `<hostname>.local`. Useful for testing on your phone or another laptop without setting up Tailscale.\n\n**`.local` TLD + LAN sharing:** macOS reserves the `.local` namespace for Bonjour/mDNS (RFC 6762), and macOS's TCP stack short-circuits self-connections to your own LAN IP via the loopback path *without consulting `pf`*, so the obvious \"redirect lo0 \u2192 my LAN IP\" trick can't fix it. Supbuddy's mDNS responder works around this by **ignoring queries that originate from this machine**, letting the OS resolver fall through to `/etc/resolver/<project-domain>` (which routes to `127.0.0.1` where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: `.local` works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to `.test`, you can switch back.\n\nIf `studio.<project>.local` (or similar) doesn't load: open the Configure tab. A red banner will tell you whether it's a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.\n\n### Tailscale\n\nIf you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.\n\n## Monorepo support\n\nSupbuddy auto-detects these monorepo layouts when scanning a project root:\n\n- Turborepo (presence of `turbo.json`)\n- pnpm workspaces (`pnpm-workspace.yaml`)\n- npm/yarn workspaces (`workspaces` field in root `package.json`)\n- Common folder layouts: `apps/*`, `packages/*`, `services/*`, `sites/*`\n\nEach detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: `apps/*`, `packages/*`, `services/*`, `sites/*`, `db/`, `db/*`, `database/`, `database/*`, `packages/backend`, `packages/db`, `packages/database`.\n\n### Detected app frameworks\n\nPort detection looks for the framework dependency in `package.json` and combines that with: explicit `-p`/`--port` in the dev script, `PORT=` env in the dev script, or a config file read. If none of those resolve, the framework default is used:\n\n| Framework dependency | Default port |\n| --- | --- |\n| `next` | 3000 |\n| `vite` | 5173 |\n| `@remix-run/dev`, `@remix-run/serve` | 3000 |\n| `astro` | 4321 |\n| `nuxt`, `nuxt3` | 3000 |\n| `@sveltejs/kit` | 5173 |\n| `@angular/core` | 4200 |\n| `@nestjs/core` | 3000 |\n| `express`, `fastify`, `koa`, `hono`, `@hono/node-server`, `elysia`, `polka`, `tinyhttp` | none (must be explicit in dev script) |\n\n### Server Actions allowedOrigins audit\n\nFor Next.js apps, Supbuddy reads your `next.config.{ts,mts,js,mjs,cjs}` and extracts the hosts in `experimental.serverActions.allowedOrigins`. If a mapped subdomain is missing from that list, the project's **warnings chip** flags `next.config: N origins missing`; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the **Apps** tab (the chip's \"open apps\" jump) where the affected app shows the warning with a **Fix** button.\n\nThe Fix button opens a dialog with a paste-ready snippet and an **Apply\u2026** button: click it to see a unified diff of the change Supbuddy will make to your `next.config`, then **Confirm & write** to apply it. Supbuddy handles the four common config shapes (existing `allowedOrigins` array, existing `serverActions` block without it, existing `experimental` block without `serverActions`, or no `experimental` at all). The edit is strictly additive: existing array entries are kept verbatim, including spreads (`...devHosts`), identifiers and comments, and only the missing origins are appended.\n\nIf `allowedOrigins` (or `serverActions`, or `experimental`) is set to something other than a plain array/object literal \u2014 an identifier, a function call, a ternary, `[...] as string[]` \u2014 Supbuddy **refuses to patch** rather than guess, and the dialog says so along with the exact origins to add. This is deliberate: a wrong rewrite would produce a duplicate key (TypeScript `TS1117`) that breaks your build long after the fact, so the fallback is the copyable snippet. Use it and edit by hand.\n\nAfter write, Supbuddy rescans the project so the warning disappears immediately. Restart your dev server for the change to take effect; Next.js does not hot-reload `next.config`. Over MCP the same audit is exposed as `preview_next_origins` / `apply_next_origins`; both return `ok: false` with an explanation in the refusal case, and `apply_next_origins` never writes a file it cannot verify.\n\n### Next.js cross-origin dev requests (allowedDevOrigins)\n\nSupbuddy proxies your dev server but **passes the browser's real `Origin` header through** (it no longer rewrites `Origin` to the upstream address). That's required so Server Actions and other origin checks see the actual page origin \u2014 but it means **Next.js 15.3+ and 16** dev servers, which validate cross-origin dev requests against `allowedDevOrigins` (defaulting to `localhost`), now treat a request arriving on a Supbuddy domain (or a Thin project's `127.0.0.N` loopback IP) as cross-origin and can reject it. Add your Supbuddy domain to `allowedDevOrigins` in `next.config`:\n\n```js\n// next.config.js\nmodule.exports = {\n allowedDevOrigins: ['web.myproject.test'],\n}\n```\n\nRestart the dev server afterward; Next.js does not hot-reload `next.config`. This is separate from `experimental.serverActions.allowedOrigins` (the Server Actions CSRF list above) \u2014 15.3+/16 may need both.\n\n### Vite allowedHosts audit\n\nFor Vite apps, Supbuddy reads your `vite.config.{ts,mts,cts,js,mjs,cjs}` and extracts `server.allowedHosts`. If a mapped host isn't covered, the **warnings chip** flags `vite: N hosts blocked`; Vite's dev server otherwise rejects proxied requests for unknown hosts with `Blocked request. This host (\"\u2026\") is not allowed.` (403). A `.your-project.local` entry counts as covering every subdomain, so an existing wildcard suffix doesn't trigger a false warning.\n\nLike the Next.js audit, the affected app's **Fix** button on the **Apps** tab opens a dialog with a paste-ready snippet and an **Apply\u2026** button that previews a unified diff and writes `server.allowedHosts` into your `vite.config` (handling an existing `allowedHosts` array, an existing `server` block without it, or no `server` block at all; `allowedHosts: true` is left untouched). The edit is strictly additive \u2014 existing entries, spreads and comments are kept verbatim and only missing hosts are appended \u2014 and, exactly as with the Next.js audit, Supbuddy **refuses to patch** when `allowedHosts` or `server` is set to anything other than a plain array/object literal, pointing you at the snippet instead of risking a duplicate-key build break. After write, Supbuddy rescans so the warning clears. Restart your dev server for the change to take effect; Vite does not hot-reload `vite.config`.\n\n## MCP setup (AI agents)\n\nSupbuddy ships a built-in MCP server on `http://127.0.0.1:9877/mcp` with static Bearer-token auth. Five clients have one-click install; any other MCP-compatible tool can be configured manually with the same URL + token.\n\nOpen **Settings \u2192 MCP \u2192 Add client**, pick the client kind, and Supbuddy generates a token, edits the client's config file, and backs up the original (`<file>.supbuddy-backup` next to it). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (**Settings \u2192 MCP \u2192 Clients**: install, edit scopes, set-primary, rotate token, revoke) drives each client from the app.\n\n### Auto-install paths\n\n| Client | Config file | Transport |\n| --- | --- | --- |\n| Claude Code | `~/.claude.json` (user) or `<project>/.mcp.json` (project) | HTTP |\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | stdio shim via `npx -y @supbuddy/mcp@latest` |\n| Cursor | `~/.cursor/mcp.json` (user) or `<project>/.cursor/mcp.json` (project) | HTTP |\n| Codex CLI | `~/.codex/config.toml` (adds an `[mcp_servers.supbuddy]` block) | HTTP |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` | HTTP |\n\n### MCP tool surface\n\nThe MCP server has full read and write access:\n\n- Read tools (`list_mappings`, `list_projects`, `get_health`, `get_compose_status`, `list_pending_vm_migrations`, etc.), with env values and request bodies included.\n- `get_client_capabilities` and `request_scope_elevation` (scope discovery + user-approved grant).\n- `read_env_file`, `tail_request_logs`, `watch_audit_log`.\n- Write tools: `create_mapping`, `delete_mapping` (soft-delete), `register_project`, `update_project`, `set_supabase_config_path`, `start_proxy`, `start_supabase`, `stop_supabase`, `restart_supabase`, `switch_isolation`, `migrate_vm_to_thin`, `finish_vm_migration`, `start_compose`, `stop_compose`, `restart_compose`, `scaffold_addons`, `seed_addons`, `write_env_file`, `copy_env_var`, `write_supabase_config`.\n- Scripts tools (`list_scripts`, `start_script`, `stop_script`, `restart_script`, `bookmark_script`, `tail_script_logs`); see *Scripts MCP tools* below.\n- Extended Supabase tools: `init_supabase`, `validate_supabase_config`, `list_supabase_backups`, `restore_supabase_backup`, `cancel_supabase_start`, `force_recreate_supabase`, `restart_supabase_container`, `get_supabase_analytics`, `set_supabase_analytics`.\n- Bundle (export/import a project's full config): `export_bundle`, `import_bundle`, `validate_bundle`.\n- Supbuddy Cloud (opt-in, per-project): `cloud_sign_in`, `push_to_cloud`, `get_cloud_status`, `preview_cloud_env`, `cloud_teardown`, plus live sync (`cloud_sync_start`, `cloud_sync_status`, `cloud_sync_stop`) \u2014 push a project (with its Supabase schema + data) to a hosted cloud stack and control it. The `cloud` link (`{ projectId, stackId, pushedAt, url }`) also appears on `get_project` / `list_projects`, so any client sees which projects are in the cloud. `get_cloud_status` also returns a `box` summary \u2014 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 \u2014 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 \u2014 meaningless inside a box), **remote** (correct as-is in both places), **secret**, or **plain**, each with a reason in plain words, plus `needs_attention` \u2014 the count of local ones, the only number that implies an action. It is read-only and changes nothing. **Secret values are never returned** \u2014 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 \u2192 8443` redirect is actually live \u2014 probed, not remembered. `enforced` is `null` whenever the probe would be meaningless \u2014 the proxy is stopped, or nothing is listening on the HTTPS port \u2014 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 \u2014 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 \u2014 a declined prompt, a timeout, or a ruleset that fails validation \u2014 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`) \u2014 it mutates nothing. `doctor_fix` ( `{ check_ids: [...] }` ) applies the opt-in repairs for those checks; it's **system-scoped and confirm-gated** (a modal, exactly like `uninstall_ca`), so a read-scoped client can't trigger a fix and an agent can't silently run a destructive repair. Backs `supbuddy doctor` / `doctor --fix` (see *System doctor*).\n- System reset: `system_wipe` ( `{ tier: \"soft\" | \"deep\" }` , scope `system`) runs the tiered reset described under *System reset*. It is gated **twice**: it always returns a plan first \u2014 even for `auto_apply` clients \u2014 whose `side_effects` are the literal manifest the wipe will execute, and the subsequent `apply` still blocks on a user confirmation modal. `tier: \"full\"` is **rejected**: it deletes the credentials the caller is authenticating with, and its final steps (uninstalling the service, removing the app-data directory) can't run inside the daemon \u2014 run `supbuddy reset --tier=full` in a terminal instead.\n- Multiple MCP clients can connect simultaneously. The same MCP-HTTP surface backs the headless **CLI** (see *Command-line interface* below).\n\n### Scopes: discovery & self-service elevation\n\nEach MCP client holds a set of **scopes** (`read`, `log_tail`, `mappings`, `projects`, `services`, `config`, `system`, `apply`) chosen when it's added. A tool call that needs a scope the client lacks fails with `scope_denied`, whose payload now carries a `user_message` and `details.remediation` pointing at the fix.\n\n- `get_client_capabilities` ( `{ tool? }` ) returns the calling client's `granted_scopes` and `available_scopes`. Pass a `tool` name to get `{ required_scope, required_feature, can_call, reason? }` so an agent can pre-flight a call instead of probing by hitting `scope_denied`.\n- `request_scope_elevation` ( `{ scopes: [...] }` ) asks the **user** to grant the named scopes. Supbuddy shows a blocking approval dialog; on approval the scopes are added to the client. Already-granted scopes short-circuit without a prompt.\n\nYou can also review and edit any client's scopes from the GUI: **Settings \u2192 MCP \u2192 Clients** lists each client's granted scopes inline and exposes a **Scopes** button that opens the same scope editor used when adding a client.\n\n### Registering a project via MCP\n\n`register_project` takes a `root_path` (required), an optional `label`, `auto_scan` (default `true`), and an optional `isolation` (`'thin'` or `'host'`). It registers the project the same way the GUI's \"Add project\" flow does:\n\n- Derives a base domain as `<slug>.<defaultTld>` from the label (or the folder name), e.g. `staffhub.test`.\n- Records both the project `path` and `rootPath` so the project is visible to the proxy, scans, and file tools alike.\n- Scans the folder (unless `auto_scan: false`) for apps, services, scripts, and package manager.\n- Creates per-app subdomain mappings from the discovered apps (e.g. `site.staffhub.test \u2192 :3400`), derives the host service subdomains (`api.`, `studio.`, \u2026), and reloads Caddy.\n- **Defaults to `thin` isolation**: the project gets its own loopback IP so its dev servers keep canonical ports (`:3000`) with no cross-project collisions \u2014 run them with `supbuddy run -- <dev command>`.\n\nA project lands on `host` in exactly three cases:\n\n1. You passed `isolation: 'host'`, or **Settings \u2192 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** \u2014 switching would rewrite its `config.toml` ports and orphan that stack, so registration keeps it on host.\n3. Thin was **attempted and failed** \u2014 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 \u2014 agents should read it instead of assuming.\n\n### Switching isolation over MCP\n\n`switch_isolation` ( `{ project_id, target_mode: 'host' | 'thin', auto_start? }` ) moves an existing project between **host** and **thin** mode. It is a destructive tool, so unless the client has auto-apply it **stages a plan** rather than switching \u2014 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 \u2014 except `system_wipe`, which always stages.\n\nA staged plan carries two fields an agent should act on:\n\n- **`can_apply: true`** \u2014 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** \u2014 never read that field as a denial.\n- **`__apply_via`** \u2014 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> \u2026`. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project's default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.\n\n### `supbuddy.addons.yml` format\n\nA valid Compose fragment (a standard `services:` map) plus an optional Supbuddy-only `x-supbuddy:` extension block. A plain `docker compose up` ignores `x-supbuddy:`, so the file stays usable without Supbuddy. Today `x-supbuddy` supports a one-shot **seed** step:\n\n```yaml\nservices:\n redis:\n image: redis:7-alpine\n ports: [\"6379:6379\"]\nx-supbuddy:\n seed:\n service: redis\n command: [\"redis-cli\", \"ping\"] # explicit argv, runs once after services are healthy\n runOnce: true\n```\n\nThe seed step runs **once** after the add-on services are up and healthy. It's idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the `seed_addons` MCP tool.\n\n### MCP tools\n\n- `scaffold_addons` ( `{ project_id }` ): scope `config`. Creates a starter `supbuddy.addons.yml` if the project doesn't have one. Never clobbers an existing file.\n- `seed_addons` ( `{ project_id, force? }` ): scope `services`. Runs the declared `x-supbuddy.seed` step. Idempotent unless `force: true`.\n- `tail_service_logs` ( `{ project_id, service }` ): scope `log_tail`. Streams a Compose/add-on service's container logs over SSE (like `tail_request_logs`, but for container stdout/stderr).\n- `watch_supabase` ( `{ project_id }` ): scope `log_tail`. Streams a project's live Supabase start/stop/restart progress over SSE: `operation` (status + message), `progress` (image-pull/service snapshots), and `log` (raw lines, VM projects). The stream ends on a terminal status. Backs `supbuddy supabase start --follow`.\n\n### Scripts MCP tools\n\nScripts detected in a project (e.g. `dev`, `build`, `test`) are controllable over MCP:\n\n- `list_scripts` ( `{ project_id }` ): scope `read`. Returns all detected scripts with their current status and bookmark state.\n- `start_script` ( `{ project_id, script }` ): scope `services`. Starts the named script process.\n- `stop_script` ( `{ project_id, script }` ): scope `services`. Stops the named script process.\n- `restart_script` ( `{ project_id, script }` ): scope `services`. Stops then starts the named script process.\n- `bookmark_script` ( `{ project_id, script, bookmarked }` ): scope `services`. Pins (`bookmarked: true`) or unpins a script in the Quick Access group.\n- `tail_script_logs` ( `{ project_id, script }` ): scope `log_tail`. Streams the named script's stdout/stderr over SSE.\n\n### `get_compose_status` shape\n\n`get_compose_status` ( `{ project_id }` ) returns per-service status, not just whether Compose is installed:\n\n```json\n{\n \"project_id\": \"\u2026\",\n \"compose_installed\": true,\n \"running\": true,\n \"services\": [\n { \"name\": \"redis\", \"status\": \"running\", \"health\": \"healthy\", \"ports\": [\"6379:6379\"], \"image\": \"redis:7-alpine\", \"container_id\": \"\u2026\", \"source\": \"addons\" }\n ],\n \"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 \u2014 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** \u2192 one global skill at `~/.claude/skills/supbuddy/SKILL.md`. **Cursor** \u2192 `~/.cursor/skills/supbuddy/SKILL.md`. The global skill self-scopes: it only acts when the working directory has a `.supbuddy/` folder, and resolves the active project from that folder's `meta.json`.\n- All other targets (`windsurf`, `continue`, the `AGENTS.md`/`CLAUDE.md`/Copilot managed blocks, JetBrains) stay **local**: their \"global\" files are shared user files, so Supbuddy won't overwrite them.\n- Each target has a **scope** setting: `auto` (default: global for the Claude/Cursor skills, local for everything else), `global`, `local` (force per-project, useful if you commit the file for teammates), or `off`. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does **not** auto-remove these global files; delete them manually from `~/.claude/skills/supbuddy/` and `~/.cursor/skills/supbuddy/` if needed.\n- The always-loaded `CLAUDE.md`/`AGENTS.md` managed block stays local as a safety net so agents stay aware even if the on-demand global skill doesn't auto-activate.\n\nSync modes per project:\n\n- **Auto**: Supbuddy regenerates the files whenever mappings, services, or project state change.\n- **Manual only**: files are only written when you click **Sync now** (or use the tray's *Sync AI context for all projects*).\n- **Off**: nothing is written.\n\nThe collapsed header shows an at-a-glance status pill: mode (`auto` / `manual` / `off`), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn't present) appear greyed out in the **Detected targets** list inside the panel.\n\n## Supbuddy Cloud\n\nPush a project \u2014 its Supabase schema **and data** \u2014 to a hosted cloud dev-stack (its own full self-hosted Supabase \u2014 Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway \u2014 as an isolated graph of machines on a per-tenant private network) and control it from the app, the CLI, or MCP. **Opt-in and per-project:** nothing cloud-related appears in a project until you've signed in.\n\n- **Invite-only, for now** \u2014 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 \u2014 only the operator can.\n- **Get started** \u2014 the top bar shows a **Get started with Supbuddy Cloud** strip; sign in (email/password) there. Once signed in it becomes **Open cloud** (opens [cloud.supbuddy.app](https://cloud.supbuddy.app) in your browser). Sign-in state + the Claude connection also live under **Settings \u2192 Cloud**.\n- **Push a project** \u2014 after signing in, each project's \u22EF menu gains **Push to cloud\u2026**. The 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* \u2014 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** \u2014 a **\u2601** badge appears on its row; click it (or \u22EF \u2192 **Open in cloud**) to open the stack in the web app.\n- **CLI / MCP** \u2014 the same flow headless: `supbuddy cloud login|push|status|teardown` (password via arg or `SUPBUDDY_CLOUD_PASSWORD`), or the `push_to_cloud` / `get_cloud_status` / `cloud_teardown` / `cloud_sign_in` MCP tools. `project ls` marks pushed projects with \u2601, and `get_project` / `list_projects` carry the `cloud` link. `cloud_teardown` (and the \u22EF teardown) destroy the remote stack and unlink it locally \u2014 routed through the same plan/apply gate as other destructive tools.\n- **Service breadth** \u2014 a self-hosted push provisions the **full** Supabase stack by default. Pass `push_to_cloud`'s `supabase_services: \"minimal\"` (MCP) to opt down to a lean db/auth/REST stack instead.\n- **Idle auto-stop** \u2014 a running cloud stack that reports no activity for ~30 minutes is automatically **stopped** to save cost (its data + config persist; start it again from the web app). A background reaper also reconciles any stack whose machines went missing.\n- **Web console** \u2014 [cloud.supbuddy.app](https://cloud.supbuddy.app) lists your org's stacks; open one for its per-service health, live status, and **start / stop / restart / tear down** controls, plus a **Recent activity** feed of control-plane events. **Push to cloud** in the console provisions a stack from a GitHub `owner/repo` (self-hosted or bring-your-own Supabase; full or minimal service set) \u2014 the code-only path; pushing a local project *with its data* still goes through the desktop app / CLI.\n\n### 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` \u2014 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 \u2014 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 \u2014 you will see *\"The name is changed, but 1 box is still\n moving to it\u2026\"* 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 \u2014 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) \u2014 the box's phase and uptime, then four sections:\n - **Apps** \u2014 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 \u2014 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** \u2014 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** \u2014 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** \u2014 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** \u2014 *\"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** \u2014 the original compact tree, still available below the panel and collapsed by default.\n\n### Live sync (local \u2194 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** \u2014 in the app, a cloud project's \u22EF menu has **Start live sync\u2026** 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** \u2014 `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 \u2014 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 *\"\u2026has 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 \u2014 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 \u2014 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** \u2014 `.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** \u2014 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 \u2014 nothing outlives\n a destroyed stack.\n- **Seeing it** \u2014 a syncing project shows its state beside the \u2601 badge: *First sync\u2026*, *In sync*,\n *n conflicts*, or *Paused \u2014 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 \u2014 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 \u2014 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>` \u2014 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 \u2014 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 \u2014 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 \u2014 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>` \u2014 on a Thin project, binds the dev server to the project's loopback IP (from `.supbuddy/meta.json`) so it keeps its canonical port (e.g. `supbuddy run -- next dev` stays on `:3000`) |\n| Health / proxy | `status`, `doctor [--fix]` (health & drift scan \u2014 see *System doctor*), `reset [--tier=soft\\|deep\\|full]` (tiered system reset \u2014 see *System reset*), `proxy status\\|start\\|stop\\|restart` |\n| Mappings | `map ls\\|add\\|get\\|set\\|enable\\|disable\\|rm\\|restore` |\n| Projects | `project ls\\|add\\|get\\|scan\\|set\\|enable\\|disable\\|rm\\|restore\\|env\\|refresh-context` |\n| Supabase | `supabase start\\|stop\\|restart\\|status <proj>` (add `--follow` to stream live progress), `supabase config apply <proj> <file>` |\n| Cloud | `cloud login <email> [<pw>]` (or `SUPBUDDY_CLOUD_PASSWORD`), `cloud push <proj> [--repo=owner/repo] [--force]`, `cloud status [<proj>]`, `cloud teardown <proj>` \u2014 push a project (with its Supabase data) to a hosted cloud stack; `project ls` marks pushed projects with \u2601 |\n| Compose | `compose up\\|down\\|restart\\|status\\|logs <proj> [svcs]` |\n| Scripts | `scripts ls\\|start\\|stop\\|restart\\|logs\\|bookmark <proj> [script]` |\n| Isolation | `isolation switch <proj> <host\\|thin>`, `isolation pending-migrations`, `migrate start\\|finish <uuid>` |\n| Certificates | `ca status\\|install\\|uninstall` |\n| Env files | `env copy <src> <key> <target>`, `env write <path> <K=V>\u2026` |\n| Settings | `settings get`, `settings set --json <patch>` |\n| MCP | `mcp add [<agent>]` (register Supbuddy into a coding agent: interactive, or `--write`/`--print`/`--prompt`), `mcp ls`, `mcp revoke <id>`, `mcp approvals apply\\|cancel <id>` |\n| Host / network | `connect`, `trust`, `tailscale`, `dns`, `pf` (port-forwarding) |\n| Logs | `logs requests [-f]`, `logs audit [-f]`, `logs get <id>` |\n| Account | `account`, `caps`, `addons scaffold\\|seed <proj>` |\n| Dashboard | `tui` (alias `dash`) |\n\nGlobal flags: `--json` (machine-readable output), `--yes` (skip confirmations), `--quiet`, `--url`/`--token` (attach to a specific/remote daemon instead of auto-discovery), `--state-dir` (override the shared dir), `--timeout`, and `-f`/`--follow` for streaming log commands and live `supabase start|stop|restart` progress.\n\nDestructive operations go through the same **plan \u2192 apply** gate as MCP (see *Plan / apply for destructive tools*); the CLI's control token is granted auto-apply, so they execute directly.\n\n### Live dashboard (TUI)\n\n```bash\nsupbuddy tui # or: sup dash\n```\n\n`supbuddy tui` opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project's isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press `r` to refresh, `q` to quit. It needs a running daemon (`supbuddy daemon --detach`); if none is found it tells you so.\n\n### System doctor\n\n```bash\nsupbuddy doctor # read-only scan; prints findings by severity\nsupbuddy doctor --fix # scan, show the repair manifest, confirm (y/N), then apply\nsupbuddy doctor --fix --only=ca-not-trusted # restrict repairs to specific check ids (comma-separated)\nsupbuddy doctor --fix --yes # skip the interactive confirm (scripting / CI)\n```\n\n`supbuddy doctor` runs a **read-only** health and drift scan and prints its findings grouped by severity \u2014 **critical**, **warning**, **info** \u2014 each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing, 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 \u2014 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 \u2014 something is definitely broken |\n| `2` | The scan **could not complete** \u2014 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 \u2014 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** \u2014 one line per fixable finding, taken from the scan you just saw \u2014 and, unless you pass `--yes`, asks `Apply these fixes? [y/N]` (default **No**) before touching anything. (The desktop app's doctor panel shows the finer-grained repair *actions* themselves; the CLI lists the findings those actions belong to.) `--only=<comma,ids>` restricts the repair to specific check ids; `--yes` skips the prompt for non-interactive use. This is the **confirm-before-harm** contract: the scan is read-only, and every repair is opt-in and gated. Fixes that need elevated access prompt for your password when they run.\n\nA repair that ends up doing nothing is reported as such, never as success: if a requested check's finding is already gone, is advisory, can't be re-checked, or names an unknown id, it's listed under **NOT APPLIED** and the command exits non-zero.\n\n**An aborted `--fix` also exits non-zero (`1`).** Declining the confirmation applies nothing, so every finding is still there \u2014 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 \u2014 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 \u2014 no projects, mappings, settings or MCP clients | Copies the file aside as `state.json.corrupt-<timestamp>` so you can hand-recover it. Nothing is deleted or rewritten |\n| `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 \u2014 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 \u2014 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 \u2014 no auto-fix.** `supbuddy proxy restart` rewrites the resolver files and reloads the OS cache. The available privileged re-apply is audit-gated \u2014 it does nothing when the files are already correct, which is exactly the unloaded case \u2014 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 \u2014 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 \u2014 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 \u2014 8 parallel lookups against a real mapping \u2014 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** \u2014 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) \u2014 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 \u2014 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 \u2192 start) |\n| `caddy-ipv4-unreachable` | critical | Caddy's loaded config declares an HTTPS listener but `127.0.0.1:<port>` **refuses** connections \u2014 every IPv4 client is cut off (browsers, curl, and the pf 443\u21928443 redirect) while the process is up and its admin API answers | **Advisory \u2014 no auto-fix.** Run `supbuddy proxy restart` to rebind. Only a connection **refused** counts: a *timeout* on a pf redirect target is normal (the reply is reverse-NAT'd back to :443 and never matches your socket), so it is never reported as a fault |\n| `ca-not-trusted` | warning | The local CA exists but the **current** root isn't trusted (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 \u2014 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** \u2014 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 \u2014 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 \u2014 the cause of Firefox-family `SEC_ERROR_BAD_SIGNATURE` | Deletes the stale roots **and installs the current one** in a single elevated batch (asks for your password). Delete-only could leave a machine with no trusted Caddy root at all when the current one wasn't in the keychain yet |\n| `orphan-caddy-container` | warning | A leftover pre-binary-era `supbuddy-caddy` Docker container | Removes the container, its `supbuddy-net` network and its data/config volumes (the `caddy:latest` image is kept) |\n| `orphan-lo0-aliases` | warning | *macOS.* `127.0.0.N` aliases on `lo0` owned by no Thin project \u2014 deleting a Thin project never tore its alias down | Removes only those aliases (asks for your password); `127.0.0.1` and any non-Supbuddy alias are left alone |\n| `orphan-dind` | warning | Docker-in-Docker containers from the retired Isolated (VM) mode belonging to no registered project \u2014 each one confirmed to actually be a DinD first | Force-removes those containers and their `<name>-docker` data volumes. **This is project data**: if you deleted a project and chose to keep its data, this is that data. The Caddy container and non-Supbuddy containers are never touched |\n| `orphan-supabase-volumes` | warning | Docker volumes of Supbuddy-managed (`sb-`-prefixed) Supabase stacks owned by no registered project | Removes those volumes. **This is database data.** Host-mode stacks, stacks you started yourself, and projects still in the MCP trash (restorable for 7 days) are never touched |\n| `orphan-launchagents` | warning | *macOS.* Legacy CA-trust LaunchAgents from older builds that re-export `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` at every login and break **public** TLS | Boots each agent out and removes it, leaving a `.supbuddy-backup` copy alongside. Root-owned agents under `/Library` may resist; the fix reports those as a failure instead of claiming success |\n| `orphan-electron-token-files` | warning | Leftover `~/.config/Supbuddy/mcp/<clientId>.bin` token files from the retired Electron app, for clients that no longer exist | Deletes those files (no elevation). They can't be decrypted any more anyway; clients that are merely revoked keep their record and are left alone |\n| `orphan-mcp-secrets` | warning | `secrets/mcp-<clientId>.secret` files whose token can no longer authenticate (client revoked, or no record at all) | Deletes those files (no elevation) \u2014 it can't log a working agent out. Secrets for current clients, and the non-MCP secrets stored alongside them (license, cloud session, Tailscale key), are left untouched |\n| `unmanaged-supabase` | info | A Supabase stack on the host daemon that maps to no registered project (e.g. a plain `supabase start`) | **Advisory \u2014 no auto-fix.** Supbuddy never tears down a stack you started yourself; run `supabase stop` in its project if you don't need it |\n| `stale-resolver-files` | info | *macOS.* Supbuddy-marked `/etc/resolver/<suffix>` files for suffixes no **enabled** project or mapping claims any more (deleted projects, a disabled one, an older per-project TLD) | Removes only those files (asks for your password); suffixes still in use are left alone. Reversible \u2014 enabling the project or restarting the proxy writes the file back |\n| `pf-conf-backups` | info | *macOS.* `/etc/pf.conf.backup.<timestamp>` copies piled up in `/etc` by older versions (which wrote a new one on every port-forwarding disable) | Removes the redundant copies, **keeping the newest one** and the stable `/etc/pf.conf.supbuddy-backup` (asks for your password) |\n| `stale-mcp-config-tokens` | info | An agent config (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, or a registered project's `.mcp.json` / `.cursor/mcp.json`) holds a `mcpServers.supbuddy` token Supbuddy no longer accepts \u2014 the 401 \"Token not recognized\" state | **Advisory \u2014 no auto-fix.** Supbuddy won't rewrite config files you own and edit. Delete the `mcpServers.supbuddy` entry from the file named in the finding, or run `supbuddy mcp add <agent>` to mint a fresh token. The finding names the file, never the token |\n| `stale-browser-nss-roots` | info | *macOS.* A Firefox / Zen / LibreWolf / Waterfox profile whose own NSS store (`cert9.db`) holds a `Caddy Local Authority` root Supbuddy can't reach | **Advisory \u2014 no auto-fix.** Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings \u2192 Privacy & Security \u2192 Certificates \u2192 View Certificates\u2026 \u2192 Authorities, delete every `Caddy Local Authority` entry, then re-import Supbuddy's CA |\n\nThe same scan and repairs are available over MCP as the `doctor` and `doctor_fix` tools (see *MCP tool surface*), and in the app under **Settings \u2192 General \u2192 System health \u2192 Scan** \u2014 the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see *Settings reference \u2192 General*). The panel has no reset button: a wipe stays a CLI operation.\n\n### System reset\n\n```bash\nsupbuddy reset # soft (the default): app state + caches\nsupbuddy reset --tier=deep # + services, Caddy containers, system integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\nsupbuddy reset --tier=deep --yes # skip the y/N confirm (scripting / CI)\nsupbuddy reset --tier=full --yes --i-understand # the ONLY scripted path for a full reset\n```\n\n`supbuddy reset` removes Supbuddy's footprint from your machine in **tiers**, and each tier is a superset of the one before it:\n\n| Tier | What it removes |\n| --- | --- |\n| `soft` (default) | App state \u2014 projects, mappings, settings, MCP clients, project-context sync and user-skill records \u2014 plus the Docker image cache (`<app-data>/image-cache`, images are re-pulled on demand) and the buffered request log. It touches **no** Docker container or volume, **nothing** under `/etc`, and **no** file in your repos, so it never asks for your password |\n| `deep` | \u2026plus: stops every service; removes the leftover Caddy container/network/volumes, **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 \u2014 `deep` unwinds what Supbuddy installed on the machine, it is not a data wipe |\n| `full` | \u2026plus **your project data, backed up first**: every Supbuddy-**managed** (`sb-`-prefixed) Supabase stack's data volumes and every DinD container with its data volume, the `.supbuddy/` directories, managed blocks and `.env.supbuddy` files in your registered repos, and **every** credential (license, live MCP tokens, cloud session, Tailscale key) \u2014 then it uninstalls the start-on-login service and empties the app-data directory. A **host-mode** project's Supabase stack is only *stopped*: those containers and volumes are yours, and they are kept |\n\nMost steps enumerate what's actually on your machine first, so anything that isn't there drops out of the manifest instead of being advertised and skipped. `soft` needs no elevated access at all. `deep` batches the pf redirect, the resolver configuration (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 \u2014 there is no reset button in the app.** The gates that make a wipe safe don't survive the trip into a GUI: a typed `RESET`, a refusal on non-interactive input, and a daemon confirmation the app itself would be answering. On top of that, `--tier=full` refuses outright while the desktop app is running (its watchdog respawns the daemon ~20s after it stops), so a button for it would be a trap. The app's **Settings \u2192 General \u2192 System health** panel points here instead.\n\n**Backup before harm.** Anything you can't regenerate \u2014 `state.json`, every managed Supabase database that is running (`pg_dump`, custom format, with a `.sha256` alongside), every managed data volume (`tar.gz`, verified with `gzip -t`) \u2014 is written to `<app-data>/backups/reset-<timestamp>/` **before** a single destructive step runs, and if any backup fails the whole reset **aborts before destroying anything**. The directory is printed prominently before you confirm, and again when the reset finishes; `manifest.json` inside it records exactly what was planned and what ran. On top of that coarse guarantee, each volume is gated individually: **no archive, no removal** \u2014 a volume with no non-empty `.tar.gz` next to it is left alone and the run records why.\n\n**A backup that can't be written stops the reset \u2014 safely.** Archiving a volume is given ten minutes; a genuinely large one (tens of GB of Postgres data plus a DinD image cache) can exceed that, and when it does the reset **aborts with nothing destroyed**. Stop the stack and prune what you don't need (`docker system prune`, drop old branches/schemas), or archive that volume yourself, then run the reset again. The same applies to any other backup failure: a full disk, an unreadable volume, a Docker daemon that stops answering.\n\n**The backups survive a full reset.** They live inside the app-data directory, so the last step of `--tier=full` empties that directory *content-wise and skips `backups/`* rather than deleting it wholesale. Move that directory somewhere safe afterwards \u2014 it's the only copy.\n\n**Confirmation.** Every tier prints the **manifest** first \u2014 the literal list of actions that will run, derived from the same actions the engine executes. `soft` and `deep` then ask `Apply this \"<tier>\" reset? [y/N]` (default **No**); `--yes` skips that prompt. `--tier=full` requires you to **type the word `RESET`** \u2014 `--yes` alone does **not** bypass it. The one scripted path for a full reset is `--yes --i-understand`, both flags together. Every prompt refuses on a non-interactive (piped) stdin rather than proceeding.\n\n**The daemon confirms too.** `soft` and `deep` run inside the daemon, which asks for its own approval before it starts \u2014 the same gate as `doctor --fix` and `ca uninstall`. With the Supbuddy app open you get a native **Allow / Deny** dialog. A daemon with neither a dialog nor a terminal \u2014 the start-on-login service, or an app-spawned daemon while the app is closed \u2014 has nobody to ask and **denies**; run a foreground `supbuddy daemon` in one terminal and the reset from a second, and it will prompt there. Don't reach for `supbuddy daemon --yes` to get past it: that auto-approves *every* confirmation for that daemon's whole lifetime.\n\n**Quit the app before a full reset.** The desktop app supervises the daemon and restarts it about 20 seconds after it stops, which would put a live daemon back into the directory the last step clears. `--tier=full` refuses up front while the app is running \u2014 before it asks you to type `RESET`, and before it changes anything. Quit the app (menu bar icon \u2192 Quit) and run it again; the quit dialog's default **Leave running** is fine, since the reset stops the daemon itself. The check looks for the *app* process only, so nothing else has to change. `--tier=full` also runs with no daemon at all, so if you quit with **Stop service** you can go straight ahead.\n\n**The order of a full reset**, once you've confirmed: the start-on-login service is uninstalled, the daemon is stopped and waited for (the reset refuses to run against a live daemon, which would rewrite `state.json` underneath it), the backup and teardown steps above run, and only then is the app-data directory emptied \u2014 keeping `backups/`. If the reset aborted, or if a daemon came back while it was running, the app-data directory is left in place and the CLI tells you so rather than clearing it under a live process.\n\n`soft` and `deep` are also available over MCP as the plan-gated `system_wipe` tool (see *MCP tool surface*). `--tier=full` is **CLI-only**: it deletes the credentials any agent would be calling with, and a daemon cannot uninstall the service it runs under or delete the directory it runs from.\n\n**What a full reset does not remove.** It only ever touches paths of **registered** projects \u2014 there is no disk scan for stray `.supbuddy` directories \u2014 and it won't delete or rewrite files whose ownership is ambiguous. So after `--tier=full` these are still on disk, and you can remove them by hand:\n\n- Per-editor rule files Supbuddy wrote in your repos: `.cursor/rules/supbuddy.mdc`, `.claude/skills/supbuddy/SKILL.md`, `.codeium/windsurf/rules/supbuddy.md`, `.continue/rules/supbuddy.md`, `.idea/supbuddy.md`. Shared files (`CLAUDE.md`, `AGENTS.md`, `.gitignore`, \u2026) keep their content and only lose Supbuddy's sentinel-delimited block.\n- Values `apply_env` merged into your **own** `.env*` files. The fully-owned `.env.supbuddy` files *are* deleted.\n- The bare `.env.supbuddy` line in `.gitignore` \u2014 it sits outside the managed block.\n- `vite.config.*` `allowedHosts` and `next.config.*` dev-origin patches.\n- `supabase/config.toml` port / `project_id` patches, when restoring the original file failed during the Thin teardown.\n- MCP client config entries written by `mcp add` / `install_mcp_config` (`~/.claude.json`, Claude Desktop, Cursor, Codex, Windsurf, a project `.mcp.json` / `.cursor/mcp.json`). The token they hold is dead the moment the secrets are deleted; `supbuddy doctor`'s `stale-mcp-config-tokens` check will name each file.\n- The `caddy:latest` Docker image (shared and re-pullable) and anything a host-mode project owns.\n- The Supbuddy app itself \u2014 drag `Supbuddy.app` to the Trash \u2014 and the backups directory, which is the whole point of keeping it.\n\n## Settings reference\n\nOpen Settings via the gear icon top-right or by clicking the tray icon \u2192 Open Dashboard \u2192 gear. Five tabs.\n\n### General\n\n- **Theme**: dark or light.\n- **Auto-start at login**: registers Supbuddy as a macOS login item. Default: on.\n- **Default TLD**: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default: `test`.\n- **Default isolation**: `host` or `thin` for newly added projects. Default: `thin` (per-project loopback IP; apps keep canonical ports like `:3000`). MCP registration additionally keeps a project on `host` when its Supabase stack is already running on the host outside Supbuddy.\n- **Auto-subdomain mapping**: when on, services and apps detected during a project scan get mappings created automatically. Default: on.\n- **Bundled-runtime trust**: installs Supbuddy's local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, \u2026) actually read. These apps don't consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to `*.test` with `unable to get local issuer certificate`. Default: prompted on first launch when one of those tools is detected.\n - **macOS**: writes `~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plist` and calls `launchctl setenv NODE_EXTRA_CA_CERTS` 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* \u2014 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 \u2014 **not** Caddy's own `caddy-data/\u2026/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 \u2014 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 \u2014 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 \u2014 an older build's value, or Caddy's `root.crt` from a hand-rolled setup \u2014 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 \u2014 the same read-only, 17-check health & drift scan as `supbuddy doctor` (see *System doctor*), in the app. Opening the panel only scans; it changes nothing.\n - Findings are grouped **critical \u2192 warning \u2192 info**, each with its title, one-line detail, concrete evidence (paths, container names, fingerprints), check id and category. **Rescan** re-runs the scan; the header shows the counts. A scan that times out says so and points at `supbuddy doctor` \u2014 the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels.\n - **Fix\u2026** on a fixable finding \u2014 or **Fix all (n)** in the header \u2014 never repairs anything by itself. It opens the **manifest**: the literal list of actions that would run, each marked *destructive* or *safe*, built from the same actions the engine executes. **Apply** stays disabled until that manifest has loaded and contains at least one action, so an empty or failed plan can't be rubber-stamped. Same confirm-before-harm contract as `doctor --fix`.\n - Repairs that need elevated access ask for your password when they run. One that outlives the app's 15-second reply window (a password prompt sitting open) is reported as *may still be running \u2014 rescan in a moment*, not as a failure.\n - Findings with no auto-fix show **advisory** instead of a Fix button; the detail says what to do by hand. Checks that couldn't run at all are listed at the bottom as *Checks that could not run*, rather than being silently dropped.\n - **There is no reset button here, on purpose** \u2014 the footer points at `supbuddy reset` instead. See *System reset*.\n\n### Network\n\n- **HTTP port**: default 8080.\n- **HTTPS port**: default 8443.\n- **DNS port**: default 5353.\n- **Port forwarding**: when on, inserts a `pfctl` rule mapping 80\u2192HTTP port and 443\u2192HTTPS port into `/etc/pf.conf` (correct translation-section placement; self-heals a file corrupted by older versions). Asks for sudo once. Status reflects a live 443 enforcement probe, not just file presence.\n- **LAN sharing**: binds Caddy to `0.0.0.0` + starts mDNS responder.\n- **Tailscale**: paste a tailnet API key to enable split-DNS push.\n- **Install / Uninstall CA**: **Install** adds Caddy's root cert to your System keychain (removing any stale same-name roots first), 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 **\u22EF** actions menu: install, edit scopes, set-primary, rotate token, revoke.\n- **Activity**: audit log with Apply/Cancel/Undo on plan rows.\n- **Trash**: soft-deleted mappings and projects, restorable for 7 days.\n- Settings: server `enabled`, `port` (default 9877), `audit_cap` (default 5000), `trash_ttl_days` (default 7).\n\n### AI Skills\n\nInstall Supbuddy's agent **skill at the user level** (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a **master on/off** plus an **autosync** toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.\n\n- **Who can install at user level**: only agents whose global file Supbuddy fully **owns** and that **self-scope** (act only when the working directory has a `.supbuddy/`): **Claude Code** (`~/.claude/skills/supbuddy/SKILL.md`) and **Cursor** (`~/.cursor/skills/supbuddy/SKILL.md`). The install is reference-counted under a synthetic `__user__` ref so it persists independent of any project and is never pruned by the boot reconcile.\n- **Master \u2194 project**: the AI Skills tab is the **master** (user-level). To commit a skill into a specific repo, use that project's **AI Tools** tab and set the target to **Project** (the old `local` scope, which writes into the repo for teammates); **User** there means the master install covers it.\n- Agents whose global file holds *your own* content (Claude `CLAUDE.md`, Codex `AGENTS.md`, Copilot, Windsurf, Continue, JetBrains) are **project-level only**: a machine-wide write there could clobber your config, so they're injected per-project instead.\n\n## Tray menu\n\nThe macOS menu bar tray icon opens a menu with:\n\n- **Status: \u2026**: current proxy state (running / idle).\n- **DNS Active (:5353)**: shown when proxy is running.\n- **LAN Sharing (\\<ip\\>)**: shown when LAN sharing is on.\n- **Tailscale (\\<ip\\>)**: shown when Tailscale is connected.\n- **Start Proxy / Stop Proxy**: opens the dashboard.\n- **Projects**: each project opens a submenu with **Apps** (click to open the mapped URL), **Supabase** services (status dot + open), and **Scripts** (your bookmarked scripts as a one-click **Start <name>** / **Stop <name>** toggle), plus **Restart Supabase**/**Restart services** and **Show in Supbuddy**.\n- **Open Dashboard**.\n- **Sync AI context for all projects**: runs the project-context sync engine for every registered project (writes `.supbuddy/`, `CLAUDE.md`, `AGENTS.md`, etc.).\n- **Show Logs**: reveals `main.log` in Finder.\n- **Check for Updates...**: manual update check (only enabled in packaged builds). The panel names all three moving parts and their versions \u2014 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 \u2014 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 \u2014 an unreadable `state.json`, an untrusted CA, a wedged Caddy, port 443 not redirecting, stale duplicate CA roots, legacy CA-trust LaunchAgents poisoning public TLS, an agent config still holding a revoked MCP token, a Firefox profile pinning an old Caddy root, and leftovers from deleted projects (Docker containers/volumes, `127.0.0.N` loopback aliases, `/etc/resolver` files, MCP token files). Add `--fix` to apply the opt-in repairs after a confirmation prompt \u2014 some checks are advisory and have no auto-fix. See [System doctor](#system-doctor) for the full check list and flags.\n\n### Browser shows \"Not secure\" or certificate warning\n\nThe Caddy CA is not trusted. Open **Settings \u2192 Network \u2192 Install Certificate**. macOS will prompt for your password \u2014 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* \u2192 **System** keychain, then the **login** keychain \u2192 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 \u2192 General \u2192 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 \u2014 `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/\u2026/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 \u2192 **Clean up leftovers\u2026** to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to `\u2026/Supbuddy/backups/<project>-<timestamp>/`). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.\n\nIf the leftover copy's data looks **newer** than the active one, the warning turns red; don't delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn't manage at all (e.g. a plain `supabase start`).\n\n### MCP client says \"Invalid OAuth error\" or \"JSON Parse error: Unexpected EOF\"\n\nThe MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in **Settings \u2192 MCP \u2192 the client's \u22EF menu \u2192 Rotate token**) or you're on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include `WWW-Authenticate: Bearer` so the client doesn't fall back to OAuth.\n\n### MCP token disappeared after app restart\n\nFixed in recent builds. If you're on an older version, regenerate the token. Root cause was that `addMcpClient` didn't trigger state persistence; the client was held in memory only.\n\n### Server Actions return 403 in a Next.js app behind Supbuddy\n\nNext.js's CSRF guard rejects POSTs whose Origin isn't in `experimental.serverActions.allowedOrigins`. Supbuddy detects this and flags it in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply\u2026** to preview a unified diff and write the change to `next.config` directly. After applying, restart your dev server.\n\nOn **Next.js 15.3+/16**, a proxied dev request can also be blocked (e.g. a \"Cross origin request detected\" warning) because Supbuddy now passes the real browser `Origin` through rather than rewriting it, and Next validates it against `allowedDevOrigins` (which defaults to `localhost`). Add your Supbuddy domain to `allowedDevOrigins` in `next.config` \u2014 see [Next.js cross-origin dev requests](#nextjs-cross-origin-dev-requests-alloweddevorigins). This is a separate key from the Server Actions list; 15.3+/16 may need both.\n\n### Vite dev server returns \"Blocked request. This host is not allowed.\" (403)\n\nVite (v5+) rejects requests whose `Host` header isn't in `server.allowedHosts`, so a Vite app reached through a Supbuddy domain 403s until the host is allowed. Supbuddy detects this and flags `vite: N hosts blocked` in the warnings chip: open the **Apps** tab and hit **Fix** on the affected app for a paste-ready snippet, or **Apply\u2026** to preview a diff and write `server.allowedHosts` into your `vite.config` directly. **Restart the Vite dev server afterward**; Vite does not hot-reload its config. A single `.your-project.local` entry covers every subdomain.\n\n### `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** \u2014 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 \u2026)\" 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 \u2014 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 \u2014 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** \u2014 not the Supbuddy proxy. Local Realtime can accept a channel join and report `SUBSCRIBED` before its logical-replication binding for the tenant is ready, so `INSERT`/`UPDATE`s in that brief window are silently missed. Give the stack a few seconds after the Supabase tab goes green, then re-subscribe (or reconnect the channel). This is **unrelated to the `.local` domain**: Kong routes `/realtime/v1/*` by path and rewrites the upstream `Host` to its internal realtime tenant, so reaching realtime through `https://api.<project>.local` behaves identically to the raw `localhost:54321` port \u2014 forwarding the `.local` host upstream does not change tenant resolution. The new `sb_publishable_*` / `sb_secret_*` API keys also work for local realtime (Kong maps them to the legacy JWT), so you don't need to switch key formats.\n\n### Project shows a red \"PROXY ERROR\" banner: domain resolves but won't load\n\nAfter the proxy starts, Supbuddy runs an end-to-end reachability check: it resolves a project domain through the OS resolver and tries to connect to Caddy on the HTTPS port. If the name resolves but the connection fails, the project shows a red **PROXY ERROR** banner naming the likely cause (DNS, port-forwarding, or mDNS race) plus a recovery action.\n\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` \u2014 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 \u2014 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 \u2014 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 \u2014 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\u21928443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click **Retry**; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off\u2192on instead. If LAN sharing is **off**, disregard any \"LAN sharing / Bonjour\" wording in the banner; the cause is the missing forward, not mDNS.\n\n**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 \u2014 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 \u2192 TLD**) or with `supbuddy project set <project> --tld=test`. (This is *not* the `.local` slowness of 3.5.17 and earlier \u2014 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 \u2014 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 \u2014 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 \u2014 **Retry** in the app, `supbuddy proxy restart`, the MCP `start_proxy` \u2014 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 \u2014 the rule being on disk isn't enough. If the rule is present but not being enforced (typically right after a reboot, or when an older Supbuddy version left `/etc/pf.conf` in a broken state), the status carries a `pf_not_enforcing` diagnostic instead of a false \"enabled\", and the banner tells you to **restart the proxy** to re-apply the redirect.\n\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 \u2014 whose Caddyfile has no site blocks, leaving nothing bound to 8443 \u2014 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 \u2014 which pf rejects, because translation rules must come before filtering rules. That silently invalidated the whole ruleset, so every later `pfctl -f` failed and 443 was dead. Current builds insert the anchor in the correct translation section and **self-heal** a file corrupted by the old version on the next proxy start. Supbuddy keeps a single stable backup at `/etc/pf.conf.supbuddy-backup` (older builds accumulated unbounded timestamped backups). If a restart doesn't fix it, inspect `/etc/pf.conf` and confirm the `rdr-anchor \"virtual.localhost\"` line sits before `anchor \"com.apple/*\"`.\n\n### Proxy came up but shows a degraded \"error\" state\n\nIf the one-time sudo prompt for port forwarding / DNS is cancelled or fails, Supbuddy no longer aborts the whole start. Caddy still starts and HTTPS keeps working on the high port (8443), and the CA is still generated; the proxy just shows an actionable **error** (degraded) state with a **Retry**. Click **Retry** and approve the sudo prompt to restore real-port (80/443) access and DNS. Until then, reach your apps on `https://<domain>:8443`.\n\n### Port already in use (8080, 8443, 5353, 9877)\n\nDefault ports: HTTP 8080, HTTPS 8443, DNS 5353, MCP 9877. Change them in **Settings \u2192 Network** / **Settings \u2192 MCP**. Find what's holding a port: `lsof -i :<port>`.\n\n### Wipe everything and start over\n\nUse `supbuddy reset` (see *System reset*) \u2014 it backs up anything you can't regenerate first, and it removes the things a plain `rm -rf` leaves behind (the pf redirect, the resolver files, the loopback aliases, the trusted CA):\n\n```bash\nsupbuddy reset --tier=soft # just the app state and caches\nsupbuddy reset --tier=deep # + services, Caddy leftovers, /etc integrations, CA trust\nsupbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data\n```\n\nThe manual equivalent, if the CLI isn't available \u2014 quit Supbuddy first, and note that this deletes `secrets/` and any backups under it with no copy anywhere:\n\n```bash\n# Wipe app data (state, certs, Caddyfile, logs, MCP tokens under secrets/)\nrm -rf ~/Library/Application\\ Support/Supbuddy\n\n# Optional: remove the trusted CA\nsudo security delete-certificate -c \"Caddy Local Authority\" /Library/Keychains/System.keychain\n```\n\n## FAQ\n\n### Is Supbuddy free?\n\nYes. Supbuddy is free. Register as many projects and mappings as you want, with full HTTPS, full DNS, full Supabase isolation, and full read and write MCP access. There are no caps and no tiers.\n\n### Does Supbuddy send my data anywhere?\n\nNo. Caddy, the DNS server, and the MCP server all run locally on your Mac. The only outbound traffic is: Tailscale split-DNS push (only if you enabled it), auto-update checks (GitHub Releases), and Google Analytics on the marketing site (not the desktop app). The desktop app does not send telemetry.\n\n### Can I work offline?\n\nYes. The app works fully offline once the CA is trusted and projects are registered.\n\n### Linux / Windows support?\n\nThe desktop app is macOS-only in v2. The headless CLI and daemon also run on Linux, where `supbuddy service install` registers a `systemd-user` start-on-login unit (macOS uses `launchd`). Windows is not supported. A few desktop code paths (certutil, update-ca-certificates) anticipate other platforms but are not tested there.\n\n### Can I use my own TLD?\n\nYes. Set any TLD in **Settings \u2192 General \u2192 Default TLD**. Supbuddy installs `/etc/resolver/<project-domain>` files that tell macOS to query our DNS server for that project's domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.\n\n### What happens if I delete a project?\n\nThe project moves to the Trash (visible in **Settings \u2192 MCP \u2192 Trash**) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.\n\n### How do I uninstall Supbuddy?\n\n1. Quit the app (the full reset refuses to run while it's open, because its watchdog restarts the daemon).\n2. Run `supbuddy reset --tier=full` and type `RESET` when it asks. This backs up your project data, then removes the containers, volumes, `/etc` integrations, CA trust, repo artifacts, credentials, the start-on-login service and the app-data directory \u2014 keeping `<app-data>/backups/reset-<timestamp>/`. See *System reset*, including the short list of things it deliberately leaves behind.\n3. Drag **Supbuddy.app** from `/Applications` to the Trash, and move the backups directory somewhere safe (or delete it).\n4. If you'd rather not use the CLI: see \"Wipe everything and start over\" above for the manual equivalent, plus `sudo security delete-certificate -c \"Caddy Local Authority\" /Library/Keychains/System.keychain` to remove the trusted CA.\n\n### Where do I report a bug?\n\nEmail support with your version (visible at the bottom of the Settings popover) and the relevant lines from `~/Library/Application Support/Supbuddy/main.log`.\n";
|
|
40043
40309
|
}
|
|
40044
40310
|
});
|
|
40045
40311
|
|
|
@@ -40484,14 +40750,14 @@ var init_managed_block = __esm({
|
|
|
40484
40750
|
});
|
|
40485
40751
|
|
|
40486
40752
|
// ../../packages/core/project-context/write-engine.ts
|
|
40487
|
-
import
|
|
40488
|
-
import
|
|
40753
|
+
import fs23 from "fs/promises";
|
|
40754
|
+
import path25 from "path";
|
|
40489
40755
|
import crypto4 from "crypto";
|
|
40490
40756
|
async function atomicWrite(absolutePath, content) {
|
|
40491
|
-
await
|
|
40757
|
+
await fs23.mkdir(path25.dirname(absolutePath), { recursive: true });
|
|
40492
40758
|
const tmp = absolutePath + ".tmp." + process.pid + "." + Date.now() + "." + crypto4.randomBytes(3).toString("hex");
|
|
40493
|
-
await
|
|
40494
|
-
await
|
|
40759
|
+
await fs23.writeFile(tmp, content, "utf-8");
|
|
40760
|
+
await fs23.rename(tmp, absolutePath);
|
|
40495
40761
|
}
|
|
40496
40762
|
function backupName(p) {
|
|
40497
40763
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
@@ -40500,7 +40766,7 @@ function backupName(p) {
|
|
|
40500
40766
|
async function writeOwned(absolutePath, content) {
|
|
40501
40767
|
let existing = null;
|
|
40502
40768
|
try {
|
|
40503
|
-
existing = await
|
|
40769
|
+
existing = await fs23.readFile(absolutePath, "utf-8");
|
|
40504
40770
|
} catch (e) {
|
|
40505
40771
|
if (e.code !== "ENOENT") throw e;
|
|
40506
40772
|
}
|
|
@@ -40511,7 +40777,7 @@ async function writeOwned(absolutePath, content) {
|
|
|
40511
40777
|
async function writeManaged(absolutePath, body, expectedChecksum, autoOverwrite) {
|
|
40512
40778
|
let existing = null;
|
|
40513
40779
|
try {
|
|
40514
|
-
existing = await
|
|
40780
|
+
existing = await fs23.readFile(absolutePath, "utf-8");
|
|
40515
40781
|
} catch (e) {
|
|
40516
40782
|
if (e.code !== "ENOENT") throw e;
|
|
40517
40783
|
}
|
|
@@ -40534,7 +40800,7 @@ async function writeManaged(absolutePath, body, expectedChecksum, autoOverwrite)
|
|
|
40534
40800
|
return { status: "conflict_skipped" };
|
|
40535
40801
|
}
|
|
40536
40802
|
const bak = backupName(absolutePath);
|
|
40537
|
-
await
|
|
40803
|
+
await fs23.copyFile(absolutePath, bak);
|
|
40538
40804
|
const replaced2 = insertOrReplaceBlock(existing, body);
|
|
40539
40805
|
await atomicWrite(absolutePath, replaced2);
|
|
40540
40806
|
return {
|
|
@@ -40557,29 +40823,29 @@ var init_write_engine = __esm({
|
|
|
40557
40823
|
});
|
|
40558
40824
|
|
|
40559
40825
|
// ../../packages/core/project-context/gitignore.ts
|
|
40560
|
-
import
|
|
40561
|
-
import
|
|
40826
|
+
import fs24 from "fs/promises";
|
|
40827
|
+
import path26 from "path";
|
|
40562
40828
|
function buildGitignoreBody(localOwnedRels = []) {
|
|
40563
40829
|
const editorEntries = [...new Set(localOwnedRels)].sort();
|
|
40564
40830
|
return ["# Supbuddy-managed (do not commit)", ...ALWAYS_IGNORED, ...editorEntries].join("\n");
|
|
40565
40831
|
}
|
|
40566
40832
|
async function ensureGitignoreEntries(projectPath, localOwnedRels = []) {
|
|
40567
|
-
const p =
|
|
40833
|
+
const p = path26.join(projectPath, ".gitignore");
|
|
40568
40834
|
let existing = "";
|
|
40569
40835
|
try {
|
|
40570
|
-
existing = await
|
|
40836
|
+
existing = await fs24.readFile(p, "utf-8");
|
|
40571
40837
|
} catch (e) {
|
|
40572
40838
|
if (e.code !== "ENOENT") throw e;
|
|
40573
40839
|
}
|
|
40574
40840
|
const updated = insertOrReplaceBlock(existing, buildGitignoreBody(localOwnedRels));
|
|
40575
40841
|
if (updated === existing) return;
|
|
40576
|
-
await
|
|
40842
|
+
await fs24.writeFile(p, updated, "utf-8");
|
|
40577
40843
|
}
|
|
40578
40844
|
async function removeGitignoreEntries(projectPath) {
|
|
40579
|
-
const p =
|
|
40845
|
+
const p = path26.join(projectPath, ".gitignore");
|
|
40580
40846
|
let existing = "";
|
|
40581
40847
|
try {
|
|
40582
|
-
existing = await
|
|
40848
|
+
existing = await fs24.readFile(p, "utf-8");
|
|
40583
40849
|
} catch (e) {
|
|
40584
40850
|
if (e.code === "ENOENT") return;
|
|
40585
40851
|
throw e;
|
|
@@ -40587,7 +40853,7 @@ async function removeGitignoreEntries(projectPath) {
|
|
|
40587
40853
|
const block = extractBlock(existing);
|
|
40588
40854
|
if (!block) return;
|
|
40589
40855
|
const stripped = (existing.slice(0, block.startIdx) + existing.slice(block.endIdx)).replace(/\n{3,}/g, "\n\n");
|
|
40590
|
-
await
|
|
40856
|
+
await fs24.writeFile(p, stripped, "utf-8");
|
|
40591
40857
|
}
|
|
40592
40858
|
var ALWAYS_IGNORED;
|
|
40593
40859
|
var init_gitignore = __esm({
|
|
@@ -40600,10 +40866,10 @@ var init_gitignore = __esm({
|
|
|
40600
40866
|
|
|
40601
40867
|
// ../../packages/core/project-context/capabilities.ts
|
|
40602
40868
|
import nodeProcess11 from "process";
|
|
40603
|
-
import
|
|
40604
|
-
import
|
|
40869
|
+
import path27 from "path";
|
|
40870
|
+
import os11 from "os";
|
|
40605
40871
|
function globalHomeDir() {
|
|
40606
|
-
return nodeProcess11.env.SUPBUDDY_HOME_DIR ||
|
|
40872
|
+
return nodeProcess11.env.SUPBUDDY_HOME_DIR || os11.homedir();
|
|
40607
40873
|
}
|
|
40608
40874
|
function resolveTargetScope(scope, cap) {
|
|
40609
40875
|
const s = scope ?? "auto";
|
|
@@ -40615,7 +40881,7 @@ function resolveTargetScope(scope, cap) {
|
|
|
40615
40881
|
}
|
|
40616
40882
|
function resolveGlobalPath(cap, homeDir) {
|
|
40617
40883
|
if (!cap.globalPathParts) return null;
|
|
40618
|
-
return
|
|
40884
|
+
return path27.join(homeDir, ...cap.globalPathParts);
|
|
40619
40885
|
}
|
|
40620
40886
|
var TARGET_CAPABILITIES;
|
|
40621
40887
|
var init_capabilities = __esm({
|
|
@@ -40639,16 +40905,16 @@ var init_capabilities = __esm({
|
|
|
40639
40905
|
});
|
|
40640
40906
|
|
|
40641
40907
|
// ../../packages/core/project-context/global-registry.ts
|
|
40642
|
-
import
|
|
40643
|
-
import
|
|
40908
|
+
import fs25 from "fs/promises";
|
|
40909
|
+
import path28 from "path";
|
|
40644
40910
|
import crypto5 from "crypto";
|
|
40645
40911
|
async function registryPath() {
|
|
40646
|
-
return
|
|
40912
|
+
return path28.join(await getAppSupportDir(), "global-context.json");
|
|
40647
40913
|
}
|
|
40648
40914
|
async function loadRegistry() {
|
|
40649
40915
|
const p = await registryPath();
|
|
40650
40916
|
try {
|
|
40651
|
-
return JSON.parse(await
|
|
40917
|
+
return JSON.parse(await fs25.readFile(p, "utf-8"));
|
|
40652
40918
|
} catch (e) {
|
|
40653
40919
|
if (e?.code !== "ENOENT") console.warn(`[global-registry] unreadable registry at ${p}, starting fresh:`, e?.message ?? e);
|
|
40654
40920
|
return {};
|
|
@@ -40659,7 +40925,7 @@ async function saveRegistry(reg) {
|
|
|
40659
40925
|
}
|
|
40660
40926
|
async function rmdirLeaf(filePath) {
|
|
40661
40927
|
try {
|
|
40662
|
-
await
|
|
40928
|
+
await fs25.rmdir(path28.dirname(filePath));
|
|
40663
40929
|
} catch {
|
|
40664
40930
|
}
|
|
40665
40931
|
}
|
|
@@ -40701,7 +40967,7 @@ function releaseGlobalFile(absPath, projectId) {
|
|
|
40701
40967
|
const refs = (rec.refs ?? []).filter((r) => r !== projectId);
|
|
40702
40968
|
if (refs.length === (rec.refs ?? []).length) return { removed: false };
|
|
40703
40969
|
if (refs.length === 0) {
|
|
40704
|
-
await
|
|
40970
|
+
await fs25.rm(absPath, { force: true });
|
|
40705
40971
|
await rmdirLeaf(absPath);
|
|
40706
40972
|
delete reg[absPath];
|
|
40707
40973
|
await saveRegistry(reg);
|
|
@@ -40735,8 +41001,8 @@ __export(sync_manager_exports, {
|
|
|
40735
41001
|
stopContextSync: () => stopContextSync,
|
|
40736
41002
|
syncProjectNow: () => syncProjectNow
|
|
40737
41003
|
});
|
|
40738
|
-
import
|
|
40739
|
-
import
|
|
41004
|
+
import path29 from "path";
|
|
41005
|
+
import fs26 from "fs/promises";
|
|
40740
41006
|
import { createRequire as createRequire2 } from "module";
|
|
40741
41007
|
function effectiveScope(slot, settings) {
|
|
40742
41008
|
if (slot === "supbuddy_folder" || slot === "gitignore") return "local";
|
|
@@ -40868,7 +41134,7 @@ async function syncProjectNow(projectId, opts = {}) {
|
|
|
40868
41134
|
try {
|
|
40869
41135
|
const r = await ensureGlobalFile(gp, content, SUPBUDDY_VERSION, projectId);
|
|
40870
41136
|
files.push({ path: gp, rel_path: gp, target: t.slot, status: r.status });
|
|
40871
|
-
await
|
|
41137
|
+
await fs26.rm(path29.join(project.path, t.rel), { force: true }).catch(() => {
|
|
40872
41138
|
});
|
|
40873
41139
|
delete checksums[t.rel];
|
|
40874
41140
|
} catch (err) {
|
|
@@ -40883,7 +41149,7 @@ async function syncProjectNow(projectId, opts = {}) {
|
|
|
40883
41149
|
continue;
|
|
40884
41150
|
}
|
|
40885
41151
|
}
|
|
40886
|
-
const abs =
|
|
41152
|
+
const abs = path29.join(project.path, t.rel);
|
|
40887
41153
|
try {
|
|
40888
41154
|
let res;
|
|
40889
41155
|
if (t.ownership === "managed-block") {
|
|
@@ -40895,7 +41161,7 @@ async function syncProjectNow(projectId, opts = {}) {
|
|
|
40895
41161
|
const prevSig = checksums[t.rel] ?? null;
|
|
40896
41162
|
let onDisk = true;
|
|
40897
41163
|
try {
|
|
40898
|
-
await
|
|
41164
|
+
await fs26.stat(abs);
|
|
40899
41165
|
} catch {
|
|
40900
41166
|
onDisk = false;
|
|
40901
41167
|
}
|
|
@@ -40939,16 +41205,16 @@ async function syncProjectNow(projectId, opts = {}) {
|
|
|
40939
41205
|
}
|
|
40940
41206
|
}
|
|
40941
41207
|
if (settings.manage_gitignore) {
|
|
40942
|
-
const gitignorePath =
|
|
41208
|
+
const gitignorePath = path29.join(project.path, ".gitignore");
|
|
40943
41209
|
try {
|
|
40944
41210
|
let before = null;
|
|
40945
41211
|
try {
|
|
40946
|
-
before = await
|
|
41212
|
+
before = await fs26.readFile(gitignorePath, "utf-8");
|
|
40947
41213
|
} catch (e) {
|
|
40948
41214
|
if (e.code !== "ENOENT") throw e;
|
|
40949
41215
|
}
|
|
40950
41216
|
await ensureGitignoreEntries(project.path, localOwnedRels);
|
|
40951
|
-
const after = await
|
|
41217
|
+
const after = await fs26.readFile(gitignorePath, "utf-8").catch(() => "");
|
|
40952
41218
|
const status = before === after ? "unchanged" : "written";
|
|
40953
41219
|
files.push({
|
|
40954
41220
|
path: gitignorePath,
|
|
@@ -40966,7 +41232,7 @@ async function syncProjectNow(projectId, opts = {}) {
|
|
|
40966
41232
|
});
|
|
40967
41233
|
}
|
|
40968
41234
|
}
|
|
40969
|
-
const metaAbs =
|
|
41235
|
+
const metaAbs = path29.join(project.path, ".supbuddy", "meta.json");
|
|
40970
41236
|
const metaRel = ".supbuddy/meta.json";
|
|
40971
41237
|
try {
|
|
40972
41238
|
const checksumsForMeta = { ...checksums };
|
|
@@ -40976,7 +41242,7 @@ async function syncProjectNow(projectId, opts = {}) {
|
|
|
40976
41242
|
const prevMetaSig = checksums[metaRel] ?? null;
|
|
40977
41243
|
let onDisk = true;
|
|
40978
41244
|
try {
|
|
40979
|
-
await
|
|
41245
|
+
await fs26.stat(metaAbs);
|
|
40980
41246
|
} catch {
|
|
40981
41247
|
onDisk = false;
|
|
40982
41248
|
}
|
|
@@ -41351,41 +41617,41 @@ var init_dind = __esm({
|
|
|
41351
41617
|
});
|
|
41352
41618
|
|
|
41353
41619
|
// ../../packages/core/system-doctor/wipe/steps/repo-artifacts.ts
|
|
41354
|
-
import
|
|
41355
|
-
import
|
|
41620
|
+
import fs27 from "fs/promises";
|
|
41621
|
+
import path30 from "path";
|
|
41356
41622
|
async function ownedEnvFiles(p, repo) {
|
|
41357
|
-
const root =
|
|
41623
|
+
const root = path30.resolve(repo);
|
|
41358
41624
|
const dirs = /* @__PURE__ */ new Set([root]);
|
|
41359
41625
|
for (const app of p.apps ?? []) {
|
|
41360
|
-
if (!app?.path || !
|
|
41361
|
-
const abs =
|
|
41362
|
-
if (abs === root || abs.startsWith(`${root}${
|
|
41626
|
+
if (!app?.path || !path30.isAbsolute(app.path)) continue;
|
|
41627
|
+
const abs = path30.resolve(app.path);
|
|
41628
|
+
if (abs === root || abs.startsWith(`${root}${path30.sep}`)) dirs.add(abs);
|
|
41363
41629
|
}
|
|
41364
41630
|
const files = [];
|
|
41365
41631
|
for (const d of dirs) {
|
|
41366
|
-
const f =
|
|
41632
|
+
const f = path30.join(d, ".env.supbuddy");
|
|
41367
41633
|
try {
|
|
41368
|
-
if ((await
|
|
41634
|
+
if ((await fs27.lstat(f)).isFile()) files.push(f);
|
|
41369
41635
|
} catch {
|
|
41370
41636
|
}
|
|
41371
41637
|
}
|
|
41372
41638
|
return files;
|
|
41373
41639
|
}
|
|
41374
41640
|
function isSafeProjectPath(p) {
|
|
41375
|
-
if (!
|
|
41376
|
-
const resolved =
|
|
41377
|
-
return resolved !==
|
|
41641
|
+
if (!path30.isAbsolute(p)) return false;
|
|
41642
|
+
const resolved = path30.resolve(p);
|
|
41643
|
+
return resolved !== path30.parse(resolved).root;
|
|
41378
41644
|
}
|
|
41379
41645
|
async function isRealDirectory(p) {
|
|
41380
41646
|
try {
|
|
41381
|
-
return (await
|
|
41647
|
+
return (await fs27.lstat(p)).isDirectory();
|
|
41382
41648
|
} catch {
|
|
41383
41649
|
return false;
|
|
41384
41650
|
}
|
|
41385
41651
|
}
|
|
41386
41652
|
async function pathExists(p) {
|
|
41387
41653
|
try {
|
|
41388
|
-
await
|
|
41654
|
+
await fs27.lstat(p);
|
|
41389
41655
|
return true;
|
|
41390
41656
|
} catch {
|
|
41391
41657
|
return false;
|
|
@@ -41394,7 +41660,7 @@ async function pathExists(p) {
|
|
|
41394
41660
|
async function recordedBlockFiles(repo, dir) {
|
|
41395
41661
|
let checksums;
|
|
41396
41662
|
try {
|
|
41397
|
-
const raw = JSON.parse(await
|
|
41663
|
+
const raw = JSON.parse(await fs27.readFile(path30.join(dir, "meta.json"), "utf-8"));
|
|
41398
41664
|
checksums = raw?.checksums ?? {};
|
|
41399
41665
|
} catch {
|
|
41400
41666
|
return [];
|
|
@@ -41402,10 +41668,10 @@ async function recordedBlockFiles(repo, dir) {
|
|
|
41402
41668
|
const files = [];
|
|
41403
41669
|
for (const rel of Object.keys(checksums)) {
|
|
41404
41670
|
if (typeof rel !== "string" || rel.startsWith(".supbuddy/")) continue;
|
|
41405
|
-
const abs =
|
|
41406
|
-
if (!abs.startsWith(`${
|
|
41671
|
+
const abs = path30.resolve(repo, rel);
|
|
41672
|
+
if (!abs.startsWith(`${path30.resolve(repo)}${path30.sep}`)) continue;
|
|
41407
41673
|
try {
|
|
41408
|
-
if (extractBlock(await
|
|
41674
|
+
if (extractBlock(await fs27.readFile(abs, "utf-8"))) files.push(abs);
|
|
41409
41675
|
} catch {
|
|
41410
41676
|
}
|
|
41411
41677
|
}
|
|
@@ -41414,13 +41680,13 @@ async function recordedBlockFiles(repo, dir) {
|
|
|
41414
41680
|
async function stripManagedBlock(file) {
|
|
41415
41681
|
let text;
|
|
41416
41682
|
try {
|
|
41417
|
-
text = await
|
|
41683
|
+
text = await fs27.readFile(file, "utf-8");
|
|
41418
41684
|
} catch {
|
|
41419
41685
|
return;
|
|
41420
41686
|
}
|
|
41421
41687
|
const block = extractBlock(text);
|
|
41422
41688
|
if (!block) return;
|
|
41423
|
-
await
|
|
41689
|
+
await fs27.writeFile(file, joinAtSeam(text.slice(0, block.startIdx), text.slice(block.endIdx)), "utf-8");
|
|
41424
41690
|
}
|
|
41425
41691
|
function joinAtSeam(before, after) {
|
|
41426
41692
|
const trailing = (/\n*$/.exec(before) ?? [""])[0].length;
|
|
@@ -41449,27 +41715,27 @@ var init_repo_artifacts = __esm({
|
|
|
41449
41715
|
for (const p of projectSnapshot()) {
|
|
41450
41716
|
const repo = p.path;
|
|
41451
41717
|
if (!repo || !isSafeProjectPath(repo)) continue;
|
|
41452
|
-
const dir =
|
|
41718
|
+
const dir = path30.join(repo, ".supbuddy");
|
|
41453
41719
|
const hasDir = await isRealDirectory(dir);
|
|
41454
41720
|
const blocks = hasDir ? await recordedBlockFiles(repo, dir) : [];
|
|
41455
41721
|
const envFiles = await ownedEnvFiles(p, repo);
|
|
41456
41722
|
if (!hasDir && blocks.length === 0 && envFiles.length === 0) continue;
|
|
41457
41723
|
const bits = [
|
|
41458
41724
|
hasDir && `remove ${dir}/`,
|
|
41459
|
-
envFiles.length > 0 && `delete ${envFiles.map((f) =>
|
|
41725
|
+
envFiles.length > 0 && `delete ${envFiles.map((f) => path30.relative(repo, f)).join(", ")}`,
|
|
41460
41726
|
"release the machine-global skill refs and the .gitignore block",
|
|
41461
|
-
blocks.length > 0 && `strip the managed Supbuddy block from ${blocks.map((b) =>
|
|
41727
|
+
blocks.length > 0 && `strip the managed Supbuddy block from ${blocks.map((b) => path30.relative(repo, b)).join(", ")}`
|
|
41462
41728
|
].filter(Boolean);
|
|
41463
41729
|
actions.push({
|
|
41464
41730
|
label: `Clean Supbuddy artifacts from "${p.name ?? p.id}" (${repo}): ${bits.join(", ")}`,
|
|
41465
41731
|
destructive: true,
|
|
41466
41732
|
run: async () => {
|
|
41467
41733
|
for (const file of blocks) await stripManagedBlock(file);
|
|
41468
|
-
for (const file of envFiles) await
|
|
41734
|
+
for (const file of envFiles) await fs27.rm(file, { force: true });
|
|
41469
41735
|
await releaseAllGlobalRefs(p.id);
|
|
41470
41736
|
await removeGitignoreEntries(repo);
|
|
41471
41737
|
if (!hasDir) return;
|
|
41472
|
-
await
|
|
41738
|
+
await fs27.rm(dir, { recursive: true, force: true });
|
|
41473
41739
|
if (await pathExists(dir)) throw new Error(`${dir} is still present after removal`);
|
|
41474
41740
|
for (const file of envFiles) {
|
|
41475
41741
|
if (await pathExists(file)) throw new Error(`${file} is still present after removal`);
|
|
@@ -41506,18 +41772,18 @@ var init_bundle_exporter = __esm({
|
|
|
41506
41772
|
});
|
|
41507
41773
|
|
|
41508
41774
|
// ../../packages/core/cloud.ts
|
|
41509
|
-
import
|
|
41775
|
+
import fs28 from "fs/promises";
|
|
41510
41776
|
import nodeProcess12 from "process";
|
|
41511
|
-
import
|
|
41777
|
+
import path31 from "path";
|
|
41512
41778
|
import { execFile as execFile7 } from "child_process";
|
|
41513
41779
|
import { promisify as promisify12 } from "util";
|
|
41514
41780
|
async function sessionPath(dir) {
|
|
41515
|
-
const d = dir ??
|
|
41516
|
-
await
|
|
41517
|
-
return
|
|
41781
|
+
const d = dir ?? path31.join(await getAppSupportDir(), "secrets");
|
|
41782
|
+
await fs28.mkdir(d, { recursive: true, mode: 448 });
|
|
41783
|
+
return path31.join(d, "cloud-session.secret");
|
|
41518
41784
|
}
|
|
41519
41785
|
async function clearCloudSession(dir) {
|
|
41520
|
-
await
|
|
41786
|
+
await fs28.rm(await sessionPath(dir), { force: true }).catch(() => {
|
|
41521
41787
|
});
|
|
41522
41788
|
}
|
|
41523
41789
|
var execFileP2, REFRESH_BUFFER_MS;
|
|
@@ -41534,8 +41800,8 @@ var init_cloud = __esm({
|
|
|
41534
41800
|
});
|
|
41535
41801
|
|
|
41536
41802
|
// ../../packages/core/system-doctor/wipe/steps/all-secrets.ts
|
|
41537
|
-
import
|
|
41538
|
-
import
|
|
41803
|
+
import fs29 from "fs/promises";
|
|
41804
|
+
import path32 from "path";
|
|
41539
41805
|
var allSecrets;
|
|
41540
41806
|
var init_all_secrets = __esm({
|
|
41541
41807
|
"../../packages/core/system-doctor/wipe/steps/all-secrets.ts"() {
|
|
@@ -41547,10 +41813,10 @@ var init_all_secrets = __esm({
|
|
|
41547
41813
|
tiers: ["full"],
|
|
41548
41814
|
destroysUserData: false,
|
|
41549
41815
|
async build(ctx) {
|
|
41550
|
-
const dir =
|
|
41816
|
+
const dir = path32.join(ctx.appSupportDir, "secrets");
|
|
41551
41817
|
let count;
|
|
41552
41818
|
try {
|
|
41553
|
-
count = (await
|
|
41819
|
+
count = (await fs29.readdir(dir)).length;
|
|
41554
41820
|
} catch {
|
|
41555
41821
|
return [];
|
|
41556
41822
|
}
|
|
@@ -41568,8 +41834,8 @@ var init_all_secrets = __esm({
|
|
|
41568
41834
|
setTailscaleApiKey(null);
|
|
41569
41835
|
await persistTailscaleKey(null);
|
|
41570
41836
|
await clearCloudSession(dir);
|
|
41571
|
-
await
|
|
41572
|
-
const left = await
|
|
41837
|
+
await fs29.rm(dir, { recursive: true, force: true });
|
|
41838
|
+
const left = await fs29.readdir(dir).catch(() => null);
|
|
41573
41839
|
if (left) {
|
|
41574
41840
|
throw new Error(
|
|
41575
41841
|
`${dir} is still present after removal` + (left.length > 0 ? ` (${left.length} secret(s) remain)` : "")
|
|
@@ -41893,9 +42159,9 @@ __export(service_exports, {
|
|
|
41893
42159
|
systemdUnit: () => systemdUnit,
|
|
41894
42160
|
uninstallService: () => uninstallService
|
|
41895
42161
|
});
|
|
41896
|
-
import
|
|
41897
|
-
import
|
|
41898
|
-
import
|
|
42162
|
+
import fs30 from "fs/promises";
|
|
42163
|
+
import os12 from "os";
|
|
42164
|
+
import path33 from "path";
|
|
41899
42165
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
41900
42166
|
import { spawnSync } from "child_process";
|
|
41901
42167
|
function launchdPlist(o) {
|
|
@@ -41918,9 +42184,9 @@ function launchdPlist(o) {
|
|
|
41918
42184
|
<key>KeepAlive</key>
|
|
41919
42185
|
<true/>
|
|
41920
42186
|
<key>StandardOutPath</key>
|
|
41921
|
-
<string>${
|
|
42187
|
+
<string>${path33.join(o.logDir, "daemon.log")}</string>
|
|
41922
42188
|
<key>StandardErrorPath</key>
|
|
41923
|
-
<string>${
|
|
42189
|
+
<string>${path33.join(o.logDir, "daemon-error.log")}</string>
|
|
41924
42190
|
</dict>
|
|
41925
42191
|
</plist>
|
|
41926
42192
|
`;
|
|
@@ -41940,16 +42206,16 @@ WantedBy=default.target
|
|
|
41940
42206
|
`;
|
|
41941
42207
|
}
|
|
41942
42208
|
function launchdPlistPath() {
|
|
41943
|
-
return
|
|
42209
|
+
return path33.join(os12.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
41944
42210
|
}
|
|
41945
42211
|
function systemdUnitPath() {
|
|
41946
|
-
const configHome = process.env.XDG_CONFIG_HOME ??
|
|
41947
|
-
return
|
|
42212
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path33.join(os12.homedir(), ".config");
|
|
42213
|
+
return path33.join(configHome, "systemd", "user", SYSTEMD_SERVICE);
|
|
41948
42214
|
}
|
|
41949
42215
|
function resolveBinPath() {
|
|
41950
|
-
const __dirname3 =
|
|
41951
|
-
const repoRoot =
|
|
41952
|
-
return
|
|
42216
|
+
const __dirname3 = path33.dirname(fileURLToPath2(import.meta.url));
|
|
42217
|
+
const repoRoot = path33.resolve(__dirname3, "..", "..", "..");
|
|
42218
|
+
return path33.join(repoRoot, "apps", "cli", "dist", "bin.js");
|
|
41953
42219
|
}
|
|
41954
42220
|
async function installService(opts = {}, run = defaultRunner2) {
|
|
41955
42221
|
const platform = process.platform;
|
|
@@ -41960,7 +42226,7 @@ async function installService(opts = {}, run = defaultRunner2) {
|
|
|
41960
42226
|
const nodePath = process.execPath;
|
|
41961
42227
|
const binPath = resolveBinPath();
|
|
41962
42228
|
try {
|
|
41963
|
-
await
|
|
42229
|
+
await fs30.access(binPath);
|
|
41964
42230
|
} catch {
|
|
41965
42231
|
console.error(
|
|
41966
42232
|
`supbuddy service: compiled binary not found at ${binPath}
|
|
@@ -41969,13 +42235,13 @@ Run \`yarn workspace supbuddy build\` first.`
|
|
|
41969
42235
|
return 1;
|
|
41970
42236
|
}
|
|
41971
42237
|
const stateDir = opts.stateDir ?? defaultStateDir();
|
|
41972
|
-
const logDir =
|
|
41973
|
-
await
|
|
42238
|
+
const logDir = path33.join(stateDir, "logs");
|
|
42239
|
+
await fs30.mkdir(logDir, { recursive: true });
|
|
41974
42240
|
if (platform === "darwin") {
|
|
41975
42241
|
const plistPath = launchdPlistPath();
|
|
41976
|
-
await
|
|
42242
|
+
await fs30.mkdir(path33.dirname(plistPath), { recursive: true });
|
|
41977
42243
|
const content2 = launchdPlist({ label: LAUNCHD_LABEL, nodePath, binPath, stateDir, logDir });
|
|
41978
|
-
await
|
|
42244
|
+
await fs30.writeFile(plistPath, content2, { encoding: "utf8", mode: 420 });
|
|
41979
42245
|
run("launchctl", ["unload", plistPath]);
|
|
41980
42246
|
const load2 = run("launchctl", ["load", "-w", plistPath]);
|
|
41981
42247
|
if (load2.code !== 0) {
|
|
@@ -41986,9 +42252,9 @@ Run \`yarn workspace supbuddy build\` first.`
|
|
|
41986
42252
|
return 0;
|
|
41987
42253
|
}
|
|
41988
42254
|
const unitPath = systemdUnitPath();
|
|
41989
|
-
await
|
|
42255
|
+
await fs30.mkdir(path33.dirname(unitPath), { recursive: true });
|
|
41990
42256
|
const content = systemdUnit({ label: LAUNCHD_LABEL, nodePath, binPath, stateDir, logDir });
|
|
41991
|
-
await
|
|
42257
|
+
await fs30.writeFile(unitPath, content, { encoding: "utf8", mode: 420 });
|
|
41992
42258
|
const reload = run("systemctl", ["--user", "daemon-reload"]);
|
|
41993
42259
|
if (reload.code !== 0) {
|
|
41994
42260
|
console.error(`supbuddy service: systemctl daemon-reload failed (exit ${reload.code})`);
|
|
@@ -42012,7 +42278,7 @@ async function uninstallService(run = defaultRunner2) {
|
|
|
42012
42278
|
const plistPath = launchdPlistPath();
|
|
42013
42279
|
let exists2 = false;
|
|
42014
42280
|
try {
|
|
42015
|
-
await
|
|
42281
|
+
await fs30.access(plistPath);
|
|
42016
42282
|
exists2 = true;
|
|
42017
42283
|
} catch {
|
|
42018
42284
|
}
|
|
@@ -42021,14 +42287,14 @@ async function uninstallService(run = defaultRunner2) {
|
|
|
42021
42287
|
return 0;
|
|
42022
42288
|
}
|
|
42023
42289
|
run("launchctl", ["unload", "-w", plistPath]);
|
|
42024
|
-
await
|
|
42290
|
+
await fs30.rm(plistPath, { force: true });
|
|
42025
42291
|
console.log(`supbuddy service: uninstalled (removed ${plistPath})`);
|
|
42026
42292
|
return 0;
|
|
42027
42293
|
}
|
|
42028
42294
|
const unitPath = systemdUnitPath();
|
|
42029
42295
|
let exists = false;
|
|
42030
42296
|
try {
|
|
42031
|
-
await
|
|
42297
|
+
await fs30.access(unitPath);
|
|
42032
42298
|
exists = true;
|
|
42033
42299
|
} catch {
|
|
42034
42300
|
}
|
|
@@ -42037,7 +42303,7 @@ async function uninstallService(run = defaultRunner2) {
|
|
|
42037
42303
|
return 0;
|
|
42038
42304
|
}
|
|
42039
42305
|
run("systemctl", ["--user", "disable", "--now", SYSTEMD_SERVICE]);
|
|
42040
|
-
await
|
|
42306
|
+
await fs30.rm(unitPath, { force: true });
|
|
42041
42307
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
42042
42308
|
console.log(`supbuddy service: uninstalled (removed ${unitPath})`);
|
|
42043
42309
|
return 0;
|
|
@@ -42052,7 +42318,7 @@ async function serviceStatus(run = defaultRunner2) {
|
|
|
42052
42318
|
const plistPath = launchdPlistPath();
|
|
42053
42319
|
let installed2 = false;
|
|
42054
42320
|
try {
|
|
42055
|
-
await
|
|
42321
|
+
await fs30.access(plistPath);
|
|
42056
42322
|
installed2 = true;
|
|
42057
42323
|
} catch {
|
|
42058
42324
|
}
|
|
@@ -42068,7 +42334,7 @@ async function serviceStatus(run = defaultRunner2) {
|
|
|
42068
42334
|
const unitPath = systemdUnitPath();
|
|
42069
42335
|
let installed = false;
|
|
42070
42336
|
try {
|
|
42071
|
-
await
|
|
42337
|
+
await fs30.access(unitPath);
|
|
42072
42338
|
installed = true;
|
|
42073
42339
|
} catch {
|
|
42074
42340
|
}
|
|
@@ -42106,10 +42372,10 @@ __export(reset_full_exports, {
|
|
|
42106
42372
|
removeAppDataPreservingBackups: () => removeAppDataPreservingBackups
|
|
42107
42373
|
});
|
|
42108
42374
|
import fsp from "fs/promises";
|
|
42109
|
-
import
|
|
42375
|
+
import path34 from "path";
|
|
42110
42376
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
42111
42377
|
async function removeAppDataPreservingBackups(appSupportDir, io2 = defaultRemoveIo) {
|
|
42112
|
-
if (!appSupportDir || !
|
|
42378
|
+
if (!appSupportDir || !path34.isAbsolute(appSupportDir)) {
|
|
42113
42379
|
throw new Error(`refusing to empty "${appSupportDir}": not an absolute app-data directory path`);
|
|
42114
42380
|
}
|
|
42115
42381
|
const entries = await io2.readdir(appSupportDir);
|
|
@@ -42125,7 +42391,7 @@ async function removeAppDataPreservingBackups(appSupportDir, io2 = defaultRemove
|
|
|
42125
42391
|
preserved.push(name);
|
|
42126
42392
|
continue;
|
|
42127
42393
|
}
|
|
42128
|
-
await io2.rm(
|
|
42394
|
+
await io2.rm(path34.join(appSupportDir, name), { recursive: true, force: true });
|
|
42129
42395
|
removed.push(name);
|
|
42130
42396
|
}
|
|
42131
42397
|
const left = (await io2.readdir(appSupportDir)).filter((n) => n !== BACKUPS_DIRNAME);
|
|
@@ -42136,7 +42402,7 @@ async function removeAppDataPreservingBackups(appSupportDir, io2 = defaultRemove
|
|
|
42136
42402
|
}
|
|
42137
42403
|
return {
|
|
42138
42404
|
appSupportDir,
|
|
42139
|
-
backupsDir:
|
|
42405
|
+
backupsDir: path34.join(appSupportDir, BACKUPS_DIRNAME),
|
|
42140
42406
|
removed,
|
|
42141
42407
|
preserved
|
|
42142
42408
|
};
|
|
@@ -42154,7 +42420,7 @@ function defaultProcessProbe(cmd, args) {
|
|
|
42154
42420
|
}
|
|
42155
42421
|
}
|
|
42156
42422
|
async function hydrateStoreFromDisk(appSupportDir, useStore3) {
|
|
42157
|
-
const raw = await fsp.readFile(
|
|
42423
|
+
const raw = await fsp.readFile(path34.join(appSupportDir, "state.json"), "utf8").catch(() => null);
|
|
42158
42424
|
if (!raw) return null;
|
|
42159
42425
|
let data;
|
|
42160
42426
|
try {
|
|
@@ -43194,10 +43460,10 @@ __export(install_exports, {
|
|
|
43194
43460
|
});
|
|
43195
43461
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
43196
43462
|
import readline2 from "readline";
|
|
43197
|
-
import
|
|
43463
|
+
import path35 from "path";
|
|
43198
43464
|
function isEphemeralNpx() {
|
|
43199
43465
|
const argv1 = process.argv[1] || "";
|
|
43200
|
-
if (argv1.includes(`${
|
|
43466
|
+
if (argv1.includes(`${path35.sep}_npx${path35.sep}`) || argv1.includes("/_npx/")) return true;
|
|
43201
43467
|
if (process.env.npm_command === "exec") return true;
|
|
43202
43468
|
return false;
|
|
43203
43469
|
}
|
|
@@ -43270,9 +43536,9 @@ var init_install = __esm({
|
|
|
43270
43536
|
var selftest_exports = {};
|
|
43271
43537
|
import net from "net";
|
|
43272
43538
|
import http2 from "http";
|
|
43273
|
-
import
|
|
43274
|
-
import
|
|
43275
|
-
import
|
|
43539
|
+
import fs31 from "fs/promises";
|
|
43540
|
+
import os13 from "os";
|
|
43541
|
+
import path36 from "path";
|
|
43276
43542
|
function getFreePort() {
|
|
43277
43543
|
return new Promise((resolve, reject) => {
|
|
43278
43544
|
const srv = net.createServer();
|
|
@@ -43356,7 +43622,7 @@ async function main() {
|
|
|
43356
43622
|
const workerPort = await getFreePort();
|
|
43357
43623
|
let mcpPort = await getFreePort();
|
|
43358
43624
|
if (mcpPort === workerPort) mcpPort = await getFreePort();
|
|
43359
|
-
const stateDir = await
|
|
43625
|
+
const stateDir = await fs31.mkdtemp(path36.join(os13.tmpdir(), "supbuddy-selftest-"));
|
|
43360
43626
|
const seed = {
|
|
43361
43627
|
projects: [],
|
|
43362
43628
|
mappings: [],
|
|
@@ -43366,7 +43632,7 @@ async function main() {
|
|
|
43366
43632
|
mcp: { enabled: true, port: mcpPort, audit_cap: 5e3, trash_ttl_days: 7 }
|
|
43367
43633
|
}
|
|
43368
43634
|
};
|
|
43369
|
-
await
|
|
43635
|
+
await fs31.writeFile(path36.join(stateDir, "state.json"), JSON.stringify(seed, null, 2));
|
|
43370
43636
|
console.log(`isolated state dir: ${stateDir}`);
|
|
43371
43637
|
console.log(`worker (Socket.IO) port: ${workerPort} MCP port: ${mcpPort}
|
|
43372
43638
|
`);
|
|
@@ -43387,7 +43653,7 @@ async function main() {
|
|
|
43387
43653
|
} finally {
|
|
43388
43654
|
console.log("\nstopping worker (graceful shutdown)...");
|
|
43389
43655
|
await handle.stop();
|
|
43390
|
-
await
|
|
43656
|
+
await fs31.rm(stateDir, { recursive: true, force: true }).catch(() => {
|
|
43391
43657
|
});
|
|
43392
43658
|
}
|
|
43393
43659
|
console.log("\n\u2500\u2500 results \u2500\u2500");
|
|
@@ -43415,10 +43681,10 @@ __export(update_exports, {
|
|
|
43415
43681
|
runUpdate: () => runUpdate
|
|
43416
43682
|
});
|
|
43417
43683
|
import https from "https";
|
|
43418
|
-
import
|
|
43684
|
+
import fs32 from "fs/promises";
|
|
43419
43685
|
import { createWriteStream as createWriteStream2 } from "fs";
|
|
43420
|
-
import
|
|
43421
|
-
import
|
|
43686
|
+
import os14 from "os";
|
|
43687
|
+
import path37 from "path";
|
|
43422
43688
|
import crypto6 from "crypto";
|
|
43423
43689
|
import readline3 from "readline";
|
|
43424
43690
|
import { execFile as execFile8, execFileSync as execFileSync3, spawn as spawn7 } from "child_process";
|
|
@@ -43520,7 +43786,7 @@ function installedVersion() {
|
|
|
43520
43786
|
}
|
|
43521
43787
|
}
|
|
43522
43788
|
async function sha256(file) {
|
|
43523
|
-
const buf = await
|
|
43789
|
+
const buf = await fs32.readFile(file);
|
|
43524
43790
|
return crypto6.createHash("sha256").update(buf).digest("hex");
|
|
43525
43791
|
}
|
|
43526
43792
|
function promptYesNo3(question) {
|
|
@@ -43534,7 +43800,7 @@ function promptYesNo3(question) {
|
|
|
43534
43800
|
}
|
|
43535
43801
|
async function canWrite(dir) {
|
|
43536
43802
|
try {
|
|
43537
|
-
await
|
|
43803
|
+
await fs32.access(dir, (await import("fs")).constants.W_OK);
|
|
43538
43804
|
return true;
|
|
43539
43805
|
} catch {
|
|
43540
43806
|
return false;
|
|
@@ -43544,20 +43810,20 @@ function sq(p) {
|
|
|
43544
43810
|
return `'${p.replace(/'/g, "'\\''")}'`;
|
|
43545
43811
|
}
|
|
43546
43812
|
async function swapApp(newApp) {
|
|
43547
|
-
const dir =
|
|
43813
|
+
const dir = path37.dirname(INSTALLED_APP);
|
|
43548
43814
|
if (await canWrite(dir)) {
|
|
43549
43815
|
const bak = `${INSTALLED_APP}.bak-${process.pid}`;
|
|
43550
|
-
if (await
|
|
43551
|
-
await
|
|
43816
|
+
if (await fs32.stat(INSTALLED_APP).then(() => true).catch(() => false)) {
|
|
43817
|
+
await fs32.rename(INSTALLED_APP, bak);
|
|
43552
43818
|
}
|
|
43553
43819
|
try {
|
|
43554
|
-
await
|
|
43820
|
+
await fs32.rename(newApp, INSTALLED_APP);
|
|
43555
43821
|
} catch (e) {
|
|
43556
|
-
await
|
|
43822
|
+
await fs32.rename(bak, INSTALLED_APP).catch(() => {
|
|
43557
43823
|
});
|
|
43558
43824
|
throw e;
|
|
43559
43825
|
}
|
|
43560
|
-
await
|
|
43826
|
+
await fs32.rm(bak, { recursive: true, force: true }).catch(() => {
|
|
43561
43827
|
});
|
|
43562
43828
|
return;
|
|
43563
43829
|
}
|
|
@@ -43619,25 +43885,25 @@ supbuddy update: could not reach the release server \u2014 ${e.message}`);
|
|
|
43619
43885
|
return 0;
|
|
43620
43886
|
}
|
|
43621
43887
|
}
|
|
43622
|
-
const tmp = await
|
|
43623
|
-
const tar =
|
|
43624
|
-
const shaFile =
|
|
43625
|
-
const extractDir =
|
|
43888
|
+
const tmp = await fs32.mkdtemp(path37.join(os14.tmpdir(), "supbuddy-update-"));
|
|
43889
|
+
const tar = path37.join(tmp, TAR_ASSET);
|
|
43890
|
+
const shaFile = path37.join(tmp, SHA_ASSET);
|
|
43891
|
+
const extractDir = path37.join(tmp, "extracted");
|
|
43626
43892
|
try {
|
|
43627
43893
|
console.log("Downloading\u2026");
|
|
43628
43894
|
await downloadAsset(pick.tarUrl, tar);
|
|
43629
43895
|
await downloadAsset(pick.shaUrl, shaFile);
|
|
43630
43896
|
console.log("Verifying checksum\u2026");
|
|
43631
|
-
const expected = (await
|
|
43897
|
+
const expected = (await fs32.readFile(shaFile, "utf8")).trim().split(/\s+/)[0];
|
|
43632
43898
|
const actual = await sha256(tar);
|
|
43633
43899
|
if (!expected || expected.toLowerCase() !== actual.toLowerCase()) {
|
|
43634
43900
|
console.error("supbuddy update: SHA-256 mismatch \u2014 refusing to install. The download may be corrupted.");
|
|
43635
43901
|
return 1;
|
|
43636
43902
|
}
|
|
43637
|
-
await
|
|
43903
|
+
await fs32.mkdir(extractDir, { recursive: true });
|
|
43638
43904
|
execFileSync3("tar", ["-xzf", tar, "-C", extractDir]);
|
|
43639
|
-
const newApp =
|
|
43640
|
-
if (!await
|
|
43905
|
+
const newApp = path37.join(extractDir, "Supbuddy.app");
|
|
43906
|
+
if (!await fs32.stat(newApp).then(() => true).catch(() => false)) {
|
|
43641
43907
|
console.error("supbuddy update: the archive did not contain Supbuddy.app.");
|
|
43642
43908
|
return 1;
|
|
43643
43909
|
}
|
|
@@ -43651,7 +43917,7 @@ supbuddy update: could not reach the release server \u2014 ${e.message}`);
|
|
|
43651
43917
|
console.error(`supbuddy update: ${e.message}`);
|
|
43652
43918
|
return 1;
|
|
43653
43919
|
} finally {
|
|
43654
|
-
await
|
|
43920
|
+
await fs32.rm(tmp, { recursive: true, force: true }).catch(() => {
|
|
43655
43921
|
});
|
|
43656
43922
|
}
|
|
43657
43923
|
if (opts.noRestart) {
|
|
@@ -43738,20 +44004,20 @@ __export(run_exports, {
|
|
|
43738
44004
|
selectDevUrls: () => selectDevUrls
|
|
43739
44005
|
});
|
|
43740
44006
|
import { spawn as spawn8, execSync as execSync3 } from "child_process";
|
|
43741
|
-
import
|
|
43742
|
-
import
|
|
44007
|
+
import fs33 from "fs";
|
|
44008
|
+
import path38 from "path";
|
|
43743
44009
|
function readMeta(startDir) {
|
|
43744
44010
|
let dir = startDir;
|
|
43745
44011
|
for (; ; ) {
|
|
43746
|
-
const metaPath =
|
|
43747
|
-
if (
|
|
44012
|
+
const metaPath = path38.join(dir, ".supbuddy", "meta.json");
|
|
44013
|
+
if (fs33.existsSync(metaPath)) {
|
|
43748
44014
|
try {
|
|
43749
|
-
return JSON.parse(
|
|
44015
|
+
return JSON.parse(fs33.readFileSync(metaPath, "utf-8"));
|
|
43750
44016
|
} catch {
|
|
43751
44017
|
return {};
|
|
43752
44018
|
}
|
|
43753
44019
|
}
|
|
43754
|
-
const parent =
|
|
44020
|
+
const parent = path38.dirname(dir);
|
|
43755
44021
|
if (parent === dir) return {};
|
|
43756
44022
|
dir = parent;
|
|
43757
44023
|
}
|
|
@@ -43885,7 +44151,7 @@ init_state_dir();
|
|
|
43885
44151
|
import { realpathSync } from "fs";
|
|
43886
44152
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
43887
44153
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
43888
|
-
import
|
|
44154
|
+
import path39 from "path";
|
|
43889
44155
|
|
|
43890
44156
|
// src/commands.ts
|
|
43891
44157
|
init_client();
|
|
@@ -44974,12 +45240,12 @@ async function runCommand(argv, flags, clientFactory) {
|
|
|
44974
45240
|
return 0;
|
|
44975
45241
|
}
|
|
44976
45242
|
if (sub === "env-read") {
|
|
44977
|
-
const
|
|
44978
|
-
if (!
|
|
45243
|
+
const path40 = rest[0];
|
|
45244
|
+
if (!path40) {
|
|
44979
45245
|
console.error("usage: supbuddy project env-read <path> [--key K]");
|
|
44980
45246
|
return 1;
|
|
44981
45247
|
}
|
|
44982
|
-
const args = { path:
|
|
45248
|
+
const args = { path: path40 };
|
|
44983
45249
|
if (typeof flags.key === "string") args.key = flags.key;
|
|
44984
45250
|
print(await client.call("read_env_file", args), flags);
|
|
44985
45251
|
return 0;
|
|
@@ -45289,8 +45555,8 @@ async function runCommand(argv, flags, clientFactory) {
|
|
|
45289
45555
|
return 0;
|
|
45290
45556
|
}
|
|
45291
45557
|
if (sub === "write") {
|
|
45292
|
-
const [
|
|
45293
|
-
if (!
|
|
45558
|
+
const [path40, ...pairs2] = rest;
|
|
45559
|
+
if (!path40 || pairs2.length === 0) {
|
|
45294
45560
|
console.error("usage: supbuddy env write <path> <key=val>...");
|
|
45295
45561
|
return 1;
|
|
45296
45562
|
}
|
|
@@ -45305,7 +45571,7 @@ async function runCommand(argv, flags, clientFactory) {
|
|
|
45305
45571
|
const v = pair.slice(eqIdx + 1);
|
|
45306
45572
|
patch[k] = v === "" ? null : v;
|
|
45307
45573
|
}
|
|
45308
|
-
print(await client.call("write_env_file", { path:
|
|
45574
|
+
print(await client.call("write_env_file", { path: path40, patch }), flags);
|
|
45309
45575
|
return 0;
|
|
45310
45576
|
}
|
|
45311
45577
|
console.error("usage: supbuddy env copy|write ...");
|
|
@@ -45844,20 +46110,20 @@ Global flags:
|
|
|
45844
46110
|
|
|
45845
46111
|
See docs/MULTIMODE_DELIVERY_ASSESSMENT.md for the full roadmap.`;
|
|
45846
46112
|
async function runShell(opts, stateDir, module) {
|
|
45847
|
-
const
|
|
45848
|
-
const
|
|
46113
|
+
const os15 = await import("os");
|
|
46114
|
+
const fs34 = await import("fs");
|
|
45849
46115
|
const shellEntry = fileURLToPath3(new URL("../../tui/src/shell/bin.tsx", import.meta.url));
|
|
45850
|
-
const REPO_ROOT2 =
|
|
45851
|
-
const tsxBin =
|
|
45852
|
-
const resultFile =
|
|
46116
|
+
const REPO_ROOT2 = path39.resolve(fileURLToPath3(import.meta.url), "..", "..", "..", "..");
|
|
46117
|
+
const tsxBin = path39.join(REPO_ROOT2, "node_modules", ".bin", "tsx");
|
|
46118
|
+
const resultFile = path39.join(os15.tmpdir(), `supbuddy-shell-${process.pid}-${Date.now()}.json`);
|
|
45853
46119
|
const env3 = { ...process.env, SUPBUDDY_SHELL_RESULT: resultFile };
|
|
45854
46120
|
if (stateDir) env3.SUPBUDDY_STATE_DIR = stateDir;
|
|
45855
46121
|
if (module) env3.SUPBUDDY_SHELL_MODULE = module;
|
|
45856
46122
|
spawnSync4(tsxBin, [shellEntry], { stdio: "inherit", env: env3 });
|
|
45857
46123
|
let chosen = null;
|
|
45858
46124
|
try {
|
|
45859
|
-
chosen = JSON.parse(
|
|
45860
|
-
|
|
46125
|
+
chosen = JSON.parse(fs34.readFileSync(resultFile, "utf8"));
|
|
46126
|
+
fs34.unlinkSync(resultFile);
|
|
45861
46127
|
} catch {
|
|
45862
46128
|
chosen = null;
|
|
45863
46129
|
}
|
|
@@ -45946,8 +46212,8 @@ async function dispatch(argv, opts) {
|
|
|
45946
46212
|
case "tui":
|
|
45947
46213
|
case "dash": {
|
|
45948
46214
|
const tuiEntry = fileURLToPath3(new URL("../../tui/src/bin.tsx", import.meta.url));
|
|
45949
|
-
const REPO_ROOT2 =
|
|
45950
|
-
const tsxBin =
|
|
46215
|
+
const REPO_ROOT2 = path39.resolve(fileURLToPath3(import.meta.url), "..", "..", "..", "..");
|
|
46216
|
+
const tsxBin = path39.join(REPO_ROOT2, "node_modules", ".bin", "tsx");
|
|
45951
46217
|
const env3 = { ...process.env };
|
|
45952
46218
|
if (typeof flags["state-dir"] === "string") env3.SUPBUDDY_STATE_DIR = flags["state-dir"];
|
|
45953
46219
|
if (typeof flags["url"] === "string") env3.SUPBUDDY_DAEMON_URL = flags["url"];
|