frizz 0.13.8 → 0.13.9

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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/frizz.js +504 -380
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -151,7 +151,7 @@ Options:
151
151
 
152
152
 
153
153
  To reach the board from a phone or another machine, press R in the terminal running it: a short
154
- walkthrough sets up a private frizz.sh name (no account needed), a custom one, a Cloudflare
154
+ walkthrough sets up a private frizz.sh name (no account needed), a Cloudflare
155
155
  Tunnel, Tailscale, or a proxy of your own, and
156
156
  remembers the choice, so a plain launch serves it from then on. The board stays on loopback and
157
157
  shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.
@@ -217,9 +217,9 @@ shows a single-use sign-in link as a QR; press L for a fresh one, or run --link
217
217
  <details>
218
218
  <summary><b>Can I reach it from anywhere, not just my LAN?</b></summary>
219
219
 
220
- > Same answer: press **R** and pick a private frizz.sh name (unguessable, no account), a custom frizz.sh name, a Cloudflare Tunnel, or Tailscale. Each is reachable from anywhere the transport is — a frizz.sh name and a Cloudflare Tunnel from the open internet, Tailscale from your own devices.
220
+ > Same answer: press **R** and pick a private frizz.sh name (unguessable, no account), a Cloudflare Tunnel, or Tailscale. (Custom frizz.sh names are paused for now; a board that already holds one keeps it.) Each is reachable from anywhere the transport is — a frizz.sh name and a Cloudflare Tunnel from the open internet, Tailscale from your own devices.
221
221
  >
222
- > Frizz has no accounts, so the single-use sign-in link **is** the door: a phone that scans it gets a session; nobody else gets in. Sessions are per device and can be listed and revoked with `npx frizz --sessions` and `npx frizz --sign-out`.
222
+ > Frizz has no accounts, so the single-use sign-in link **is** the door: a phone that scans it gets a session; nobody else gets in. Sessions are per device and can be listed and revoked with `npx frizz --sessions` and `npx frizz --sign-out`. Every project added or removed, thread started, message sent and question answered is recorded in `logs/audit.jsonl` under Frizz's data folder, with the session id of the device that did it and whether it came through that public address.
223
223
 
224
224
  </details>
225
225
 
package/dist/frizz.js CHANGED
@@ -450,6 +450,15 @@ var init_access_codes = __esm({
450
450
  const id = _AccessStore.idOf(payload);
451
451
  return !(id && this.sessions.isRevoked(id));
452
452
  }
453
+ /**
454
+ * Which device this session belongs to, for the audit trail: its record id (what `frizz --sessions`
455
+ * lists and `--sign-out` takes), `"legacy"` for a session minted before ids existed, or null when it
456
+ * does not verify. Never the session itself — the id is the one part of it that is not a credential.
457
+ */
458
+ sessionIdOf(session) {
459
+ if (!session || !this.verifySession(session)) return null;
460
+ return _AccessStore.idOf(session.slice(0, session.lastIndexOf("."))) ?? "legacy";
461
+ }
453
462
  /**
454
463
  * Sign out the device HOLDING this session — the "Sign out this device" row, not `--sign-out`.
455
464
  *
@@ -846,6 +855,290 @@ code{font-family:var(--mono);font-size:.92em;padding:.1em .35em;border-radius:4p
846
855
  }
847
856
  });
848
857
 
858
+ // packages/server/src/frizz-paths.ts
859
+ import { existsSync as existsSync2 } from "node:fs";
860
+ import { homedir as homedir2, tmpdir } from "node:os";
861
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve, sep as sep2 } from "node:path";
862
+ function legacyFrizzRoot(home = homedir2()) {
863
+ return join3(home, LEGACY_DIR_NAME);
864
+ }
865
+ function xdg(env, name) {
866
+ const value = env[name];
867
+ return value && (value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value)) ? value : void 0;
868
+ }
869
+ function insideHome(child, home) {
870
+ const norm = (value) => value.replace(/[\\/]+$/u, "").replaceAll("\\", "/").toLowerCase();
871
+ const parent = norm(home);
872
+ const inner = norm(child);
873
+ return inner === parent || inner.startsWith(`${parent}/`);
874
+ }
875
+ function windowsRoots(env, home) {
876
+ const local = env.LOCALAPPDATA && insideHome(env.LOCALAPPDATA, home) ? env.LOCALAPPDATA : join3(home, "AppData", "Local");
877
+ const base = join3(local, "Frizz");
878
+ return {
879
+ data: join3(base, "Data"),
880
+ state: join3(base, "State"),
881
+ cache: join3(base, "Cache")
882
+ };
883
+ }
884
+ function darwinRoots(home) {
885
+ const support = join3(home, "Library", "Application Support", "Frizz");
886
+ return {
887
+ data: support,
888
+ // macOS has no state directory concept; Application Support is where this belongs, and the log
889
+ // files themselves already live under each project's own directory.
890
+ state: support,
891
+ cache: join3(home, "Library", "Caches", "Frizz")
892
+ };
893
+ }
894
+ function xdgRoots(env, home) {
895
+ return {
896
+ data: join3(xdg(env, "XDG_DATA_HOME") ?? join3(home, ".local", "share"), "frizz"),
897
+ state: join3(xdg(env, "XDG_STATE_HOME") ?? join3(home, ".local", "state"), "frizz"),
898
+ cache: join3(xdg(env, "XDG_CACHE_HOME") ?? join3(home, ".cache"), "frizz")
899
+ };
900
+ }
901
+ function frizzPaths(options2 = {}) {
902
+ const env = options2.env ?? process.env;
903
+ const platform = options2.platform ?? process.platform;
904
+ const home = options2.home ?? homedir2();
905
+ const exists = options2.exists ?? existsSync2;
906
+ const legacyRoot = legacyFrizzRoot(home);
907
+ if (exists(legacyRoot)) {
908
+ return { data: legacyRoot, state: legacyRoot, cache: legacyRoot, legacy: true };
909
+ }
910
+ const explicit = {
911
+ data: xdg(env, "XDG_DATA_HOME"),
912
+ state: xdg(env, "XDG_STATE_HOME"),
913
+ cache: xdg(env, "XDG_CACHE_HOME")
914
+ };
915
+ const platformRoots = platform === "win32" ? windowsRoots(env, home) : platform === "darwin" ? darwinRoots(home) : xdgRoots(env, home);
916
+ return {
917
+ data: explicit.data ? join3(explicit.data, "frizz") : platformRoots.data,
918
+ state: explicit.state ? join3(explicit.state, "frizz") : platformRoots.state,
919
+ cache: explicit.cache ? join3(explicit.cache, "frizz") : platformRoots.cache,
920
+ legacy: false
921
+ };
922
+ }
923
+ function frizzRoots() {
924
+ return cached ??= frizzPaths();
925
+ }
926
+ function projectStateDir(projectId, home) {
927
+ return join3(home ? frizzPaths({ home }).data : frizzRoots().data, "projects", projectId);
928
+ }
929
+ var LEGACY_DIR_NAME, cached;
930
+ var init_frizz_paths = __esm({
931
+ "packages/server/src/frizz-paths.ts"() {
932
+ "use strict";
933
+ LEGACY_DIR_NAME = ".frizz";
934
+ }
935
+ });
936
+
937
+ // packages/server/src/logging.ts
938
+ import {
939
+ appendFileSync,
940
+ mkdirSync as mkdirSync2,
941
+ openSync,
942
+ readdirSync,
943
+ rmSync,
944
+ statSync as statSync2,
945
+ symlinkSync,
946
+ writeFileSync as writeFileSync2,
947
+ writeSync
948
+ } from "node:fs";
949
+ import { homedir as homedir3 } from "node:os";
950
+ import { join as join4 } from "node:path";
951
+ function defaultLogRoot(stateDir, home = homedir3()) {
952
+ return stateDir ? join4(stateDir, "logs") : join4(frizzPaths({ home }).state, "logs");
953
+ }
954
+ function runStamp(at) {
955
+ const pad = (value) => String(value).padStart(2, "0");
956
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}-${pad(at.getMinutes())}-${pad(at.getSeconds())}`;
957
+ }
958
+ function runLogPath(stateDir, at = /* @__PURE__ */ new Date(), pid = process.pid, home = homedir3(), env = process.env) {
959
+ const name = `frizz-${runStamp(at)}-${pid}.log`;
960
+ const override = env[LOG_PATH_ENV]?.trim();
961
+ if (override) return override.endsWith(".log") ? override : join4(override, name);
962
+ return join4(defaultLogRoot(stateDir, home), name);
963
+ }
964
+ function latestLogPath(dir) {
965
+ return join4(dir, "latest.log");
966
+ }
967
+ function linkLatest(dir, target2) {
968
+ const link = latestLogPath(dir);
969
+ try {
970
+ rmSync(link, { force: true });
971
+ symlinkSync(target2, link);
972
+ } catch {
973
+ try {
974
+ writeFileSync2(link, `${target2}
975
+ `, { mode: 384 });
976
+ } catch {
977
+ }
978
+ }
979
+ }
980
+ function pruneRunLogs(dir, keep = RETAINED_RUNS, days = RETAINED_DAYS, now = Date.now()) {
981
+ let entries;
982
+ try {
983
+ entries = readdirSync(dir).filter((name) => name.endsWith(".log") && name !== "latest.log");
984
+ } catch {
985
+ return;
986
+ }
987
+ const dated = entries.map((name) => {
988
+ try {
989
+ return { name, at: statSync2(join4(dir, name)).mtimeMs };
990
+ } catch {
991
+ return { name, at: 0 };
992
+ }
993
+ }).sort((a, b) => b.at - a.at);
994
+ const cutoff = now - days * 24 * 60 * 60 * 1e3;
995
+ const stale = dated.filter((entry, index) => index >= keep || entry.at < cutoff);
996
+ for (const entry of stale) {
997
+ try {
998
+ rmSync(join4(dir, entry.name), { force: true });
999
+ } catch {
1000
+ }
1001
+ }
1002
+ }
1003
+ function formatDiskLine(record2) {
1004
+ const at = new Date(record2.at);
1005
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
1006
+ const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}.${pad(at.getMilliseconds(), 3)}`;
1007
+ return `${clock} ${record2.level.toUpperCase().padEnd(5)} ${record2.scope.padEnd(12)} ${record2.message}
1008
+ `;
1009
+ }
1010
+ function openLogFile(path) {
1011
+ try {
1012
+ mkdirSync2(join4(path, ".."), { recursive: true, mode: 448 });
1013
+ return openSync(path, "a", 384);
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ }
1018
+ function createLogger(options2 = {}) {
1019
+ const path = options2.file === null ? null : options2.file ?? runLogPath(options2.stateDir);
1020
+ const level = options2.level ?? "debug";
1021
+ const now = options2.now ?? Date.now;
1022
+ const threshold = LEVEL_ORDER[level];
1023
+ const maxBytes = options2.maxBytes ?? MAX_LOG_BYTES;
1024
+ let fd = path === null ? null : openLogFile(path);
1025
+ if (fd !== null && path !== null && options2.owner !== false) {
1026
+ const dir = join4(path, "..");
1027
+ pruneRunLogs(dir);
1028
+ linkLatest(dir, path);
1029
+ }
1030
+ let written = 0;
1031
+ const listeners = /* @__PURE__ */ new Set();
1032
+ const emit = (recordLevel, scope, message) => {
1033
+ if (LEVEL_ORDER[recordLevel] < threshold) return;
1034
+ const record2 = { at: now(), level: recordLevel, scope, message };
1035
+ if (fd !== null) {
1036
+ try {
1037
+ const line = formatDiskLine(record2);
1038
+ if (written + line.length > maxBytes) {
1039
+ writeSync(fd, `--- log truncated at ${maxBytes} bytes ---
1040
+ `);
1041
+ fd = null;
1042
+ } else {
1043
+ writeSync(fd, line);
1044
+ written += line.length;
1045
+ }
1046
+ } catch {
1047
+ fd = null;
1048
+ }
1049
+ }
1050
+ for (const listener of listeners) {
1051
+ try {
1052
+ listener(record2);
1053
+ } catch {
1054
+ }
1055
+ }
1056
+ };
1057
+ return {
1058
+ debug: (scope, message) => emit("debug", scope, message),
1059
+ info: (scope, message) => emit("info", scope, message),
1060
+ warn: (scope, message) => emit("warn", scope, message),
1061
+ error: (scope, message) => emit("error", scope, message),
1062
+ get file() {
1063
+ return fd === null ? null : path;
1064
+ },
1065
+ onRecord(listener) {
1066
+ listeners.add(listener);
1067
+ return () => listeners.delete(listener);
1068
+ },
1069
+ close() {
1070
+ listeners.clear();
1071
+ fd = null;
1072
+ }
1073
+ };
1074
+ }
1075
+ function setAmbientLogger(logger2) {
1076
+ ambient = logger2;
1077
+ return logger2;
1078
+ }
1079
+ function formatFeedLine(record2) {
1080
+ return formatDiskLine(record2).trimEnd();
1081
+ }
1082
+ function attachTerminalMirror(logger2, enabled = process.env.FRIZZ_DEBUG === "1") {
1083
+ if (!enabled) return () => {
1084
+ };
1085
+ return logger2.onRecord((record2) => process.stderr.write(`${formatFeedLine(record2)}
1086
+ `));
1087
+ }
1088
+ function ambientLogger() {
1089
+ if (ambient) return ambient;
1090
+ const inherited = process.env[LOG_FILE_ENV] ?? process.env[LOG_PATH_ENV];
1091
+ const level = process.env[LOG_LEVEL_ENV];
1092
+ const logger2 = createLogger({
1093
+ ...inherited ? { file: inherited, owner: false } : { file: null },
1094
+ ...level && level in LEVEL_ORDER ? { level } : {}
1095
+ });
1096
+ attachTerminalMirror(logger2);
1097
+ ambient = logger2;
1098
+ return ambient;
1099
+ }
1100
+ function logEnvironment(logger2, level = "debug") {
1101
+ return logger2.file ? { [LOG_FILE_ENV]: logger2.file, [LOG_LEVEL_ENV]: level } : {};
1102
+ }
1103
+ var LEVEL_ORDER, LOG_FILE_ENV, LOG_LEVEL_ENV, LOG_PATH_ENV, RETAINED_RUNS, RETAINED_DAYS, MAX_LOG_BYTES, ambient, log;
1104
+ var init_logging = __esm({
1105
+ "packages/server/src/logging.ts"() {
1106
+ "use strict";
1107
+ init_frizz_paths();
1108
+ LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
1109
+ LOG_FILE_ENV = "FRIZZ_LOG_FILE";
1110
+ LOG_LEVEL_ENV = "FRIZZ_LOG_LEVEL";
1111
+ LOG_PATH_ENV = "FRIZZ_LOG_PATH";
1112
+ RETAINED_RUNS = 20;
1113
+ RETAINED_DAYS = 14;
1114
+ MAX_LOG_BYTES = 32 * 1024 * 1024;
1115
+ ambient = null;
1116
+ log = {
1117
+ debug: (scope, message) => ambientLogger().debug(scope, message),
1118
+ info: (scope, message) => ambientLogger().info(scope, message),
1119
+ warn: (scope, message) => ambientLogger().warn(scope, message),
1120
+ error: (scope, message) => ambientLogger().error(scope, message)
1121
+ };
1122
+ }
1123
+ });
1124
+
1125
+ // packages/server/src/audit.ts
1126
+ import { AsyncLocalStorage } from "node:async_hooks";
1127
+ function encodeCallerStamp(stamp) {
1128
+ return Buffer.from(JSON.stringify(stamp)).toString("base64url");
1129
+ }
1130
+ var CALLER_HEADER, requestOrigin;
1131
+ var init_audit = __esm({
1132
+ "packages/server/src/audit.ts"() {
1133
+ "use strict";
1134
+ init_access_codes();
1135
+ init_frizz_paths();
1136
+ init_logging();
1137
+ CALLER_HEADER = "x-frizz-caller";
1138
+ requestOrigin = new AsyncLocalStorage();
1139
+ }
1140
+ });
1141
+
849
1142
  // node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js
850
1143
  var require_identity = __commonJS({
851
1144
  "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js"(exports) {
@@ -15275,6 +15568,20 @@ ${DISPATCH_TASK_BANNER}
15275
15568
  // pairing, and `projectTranscriptPeerNames` uses this id to ask it. Never a drawer key on its own —
15276
15569
  // that is `peerDispatchId`, which the same pass can also supply once this resolves.
15277
15570
  peerSenderTaskId: external_exports.string().optional(),
15571
+ // ANOTHER TOP-LEVEL SESSION wrote this user turn — a thread messaging this one through Claude Code's
15572
+ // cross-session channel (see parseCrossSessionMessage), most often the coordinator thread that spawned
15573
+ // it. Not the human, and not a sub-agent either: the chat draws it as an expandable hairline naming
15574
+ // the sending thread, with the body one click away. `displayText` carries the unwrapped body.
15575
+ //
15576
+ // `name` is the sender's Claude Code session name, the fallback label. `thread` is the Frizz thread
15577
+ // behind it, filled in by the server from the sender's socket (session-peer.ts) — absent when the
15578
+ // sender is not a Frizz thread or is gone without having been resolved, and then the line names the
15579
+ // session without a link. `project` is the URL slug of the sender's project.
15580
+ sessionPeer: external_exports.object({
15581
+ name: external_exports.string().optional(),
15582
+ socket: external_exports.string().optional(),
15583
+ thread: external_exports.object({ project: external_exports.string().optional(), slug: external_exports.string(), title: external_exports.string() }).optional()
15584
+ }).optional(),
15278
15585
  // FRIZZ REFUSED the ```awaiting fence this message ends in — it named something that is not running,
15279
15586
  // or named nothing at all, or used a retired line kind (see `isParkCorrection`). The fence is not a
15280
15587
  // park, so the chat draws nothing for it: an hourglass card with a park button asserts a wait that
@@ -15384,8 +15691,9 @@ function responseJson(res, status, value) {
15384
15691
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
15385
15692
  res.end(JSON.stringify(value));
15386
15693
  }
15387
- function proxyHeaders(req, childPort, vouchSameOrigin = false) {
15694
+ function proxyHeaders(req, childPort, callerStamp, vouchSameOrigin = false) {
15388
15695
  const headers = { ...req.headers };
15696
+ headers[CALLER_HEADER] = callerStamp;
15389
15697
  headers.host = `127.0.0.1:${childPort}`;
15390
15698
  if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${childPort}`;
15391
15699
  for (const name of FORWARDED_HEADERS) delete headers[name];
@@ -15402,6 +15710,15 @@ function readCookie(header, name) {
15402
15710
  }
15403
15711
  return void 0;
15404
15712
  }
15713
+ function firstHeader(value) {
15714
+ const first = Array.isArray(value) ? value[0] : value;
15715
+ return first?.trim() || void 0;
15716
+ }
15717
+ function isLoopbackAddress(address) {
15718
+ if (!address) return false;
15719
+ const v4 = address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address;
15720
+ return v4.startsWith("127.") || address === "::1";
15721
+ }
15405
15722
  function isControlRequest(req) {
15406
15723
  const url = new URL(req.url ?? "/", "http://frizz.invalid");
15407
15724
  return url.pathname === SUPERVISOR_RESTART_PATH || url.pathname === SUPERVISOR_UPDATE_RESTART_PATH || url.pathname === SUPERVISOR_STATUS_PATH || url.pathname === SUPERVISOR_ACCESS_CODE_PATH || url.pathname === SUPERVISOR_SESSIONS_PATH || url.pathname === SUPERVISOR_SIGN_OUT_PATH;
@@ -15435,6 +15752,7 @@ var init_restart_supervisor = __esm({
15435
15752
  init_local_origin();
15436
15753
  init_local_image();
15437
15754
  init_supervisor_pages();
15755
+ init_audit();
15438
15756
  init_src();
15439
15757
  SUPERVISOR_CONTROL_PREFIX = "/_frizz/control";
15440
15758
  SUPERVISOR_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/restart`;
@@ -15799,6 +16117,28 @@ var init_restart_supervisor = __esm({
15799
16117
  if (!this.publicOrigin) return true;
15800
16118
  return this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
15801
16119
  }
16120
+ /**
16121
+ * What this proxy knows about who sent a request, for the application server's audit trail
16122
+ * (audit.ts). The session is named by its record id, never its value, and only once it verifies.
16123
+ *
16124
+ * `clientIp` is only ever taken from a request that arrived as the public origin, and it is that
16125
+ * origin's CLAIM: a relay or tunnel reaches this port from loopback, so the real visitor address
16126
+ * exists only in what the relay chose to forward (`cf-connecting-ip` from Cloudflare).
16127
+ */
16128
+ callerStamp(req) {
16129
+ const remote2 = req.socket.remoteAddress ?? null;
16130
+ const via = this.arrivedPublicly(req) ? "public" : isLoopbackAddress(remote2) ? "loopback" : "network";
16131
+ const host = via === "loopback" ? void 0 : firstHeader(req.headers.host);
16132
+ const forwarded = firstHeader(req.headers["x-forwarded-for"])?.split(",")[0]?.trim();
16133
+ const clientIp = via === "public" ? firstHeader(req.headers["cf-connecting-ip"]) ?? firstHeader(req.headers["x-real-ip"]) ?? forwarded : void 0;
16134
+ return encodeCallerStamp({
16135
+ via,
16136
+ remote: remote2,
16137
+ ...host ? { host: host.slice(0, 200) } : {},
16138
+ ...clientIp ? { clientIp: clientIp.slice(0, 200) } : {},
16139
+ session: this.access.sessionIdOf(readCookie(req.headers.cookie, SESSION_COOKIE))
16140
+ });
16141
+ }
15802
16142
  /** Did this request arrive through the public origin carrying a live session? Loopback never does. */
15803
16143
  carriesRemoteSession(req) {
15804
16144
  return this.arrivedPublicly(req) && this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
@@ -15889,143 +16229,64 @@ var init_restart_supervisor = __esm({
15889
16229
  }
15890
16230
  const childPort = this.options.childPort();
15891
16231
  if (!childPort) {
15892
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "2" });
15893
- res.end(recoveryPage(req.url ?? "/"));
15894
- return;
15895
- }
15896
- const upstream = requestHttp({
15897
- host: "127.0.0.1",
15898
- port: childPort,
15899
- method: req.method,
15900
- path: req.url,
15901
- headers: proxyHeaders(req, childPort, this.vouchesSameOrigin(req))
15902
- }, (upstreamResponse) => {
15903
- res.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
15904
- upstreamResponse.pipe(res);
15905
- });
15906
- upstream.once("error", () => {
15907
- if (res.headersSent) return res.destroy();
15908
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
15909
- res.end(recoveryPage(req.url ?? "/", "unreachable"));
15910
- });
15911
- req.pipe(upstream);
15912
- }
15913
- handleUpgrade(req, socket, head) {
15914
- if (this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
15915
- rejectWebSocketUpgrade(socket, 401, "Unauthorized");
15916
- return;
15917
- }
15918
- if (!this.authorityAccepted(req, false)) {
15919
- socket.destroy();
15920
- return;
15921
- }
15922
- const childPort = this.options.childPort();
15923
- if (!childPort) {
15924
- socket.destroy();
15925
- return;
15926
- }
15927
- const upstream = connect(childPort, "127.0.0.1");
15928
- for (const half of [socket, upstream]) {
15929
- this.upgradedSockets.add(half);
15930
- half.once("close", () => this.upgradedSockets.delete(half));
15931
- }
15932
- upstream.once("connect", () => {
15933
- const headers = proxyHeaders(req, childPort);
15934
- headers.connection = req.headers.connection ?? "Upgrade";
15935
- const lines = [`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`];
15936
- for (const [name, value] of Object.entries(headers)) {
15937
- if (value === void 0) continue;
15938
- for (const entry of Array.isArray(value) ? value : [value]) lines.push(`${name}: ${entry}`);
15939
- }
15940
- upstream.write(`${lines.join("\r\n")}\r
15941
- \r
15942
- `);
15943
- if (head.length > 0) upstream.write(head);
15944
- socket.pipe(upstream).pipe(socket);
15945
- });
15946
- upstream.once("error", () => socket.destroy());
15947
- socket.once("error", () => upstream.destroy());
15948
- }
15949
- };
15950
- }
15951
- });
15952
-
15953
- // packages/server/src/frizz-paths.ts
15954
- import { existsSync as existsSync2 } from "node:fs";
15955
- import { homedir as homedir2, tmpdir } from "node:os";
15956
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve, sep as sep2 } from "node:path";
15957
- function legacyFrizzRoot(home = homedir2()) {
15958
- return join3(home, LEGACY_DIR_NAME);
15959
- }
15960
- function xdg(env, name) {
15961
- const value = env[name];
15962
- return value && (value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value)) ? value : void 0;
15963
- }
15964
- function insideHome(child, home) {
15965
- const norm = (value) => value.replace(/[\\/]+$/u, "").replaceAll("\\", "/").toLowerCase();
15966
- const parent = norm(home);
15967
- const inner = norm(child);
15968
- return inner === parent || inner.startsWith(`${parent}/`);
15969
- }
15970
- function windowsRoots(env, home) {
15971
- const local = env.LOCALAPPDATA && insideHome(env.LOCALAPPDATA, home) ? env.LOCALAPPDATA : join3(home, "AppData", "Local");
15972
- const base = join3(local, "Frizz");
15973
- return {
15974
- data: join3(base, "Data"),
15975
- state: join3(base, "State"),
15976
- cache: join3(base, "Cache")
15977
- };
15978
- }
15979
- function darwinRoots(home) {
15980
- const support = join3(home, "Library", "Application Support", "Frizz");
15981
- return {
15982
- data: support,
15983
- // macOS has no state directory concept; Application Support is where this belongs, and the log
15984
- // files themselves already live under each project's own directory.
15985
- state: support,
15986
- cache: join3(home, "Library", "Caches", "Frizz")
15987
- };
15988
- }
15989
- function xdgRoots(env, home) {
15990
- return {
15991
- data: join3(xdg(env, "XDG_DATA_HOME") ?? join3(home, ".local", "share"), "frizz"),
15992
- state: join3(xdg(env, "XDG_STATE_HOME") ?? join3(home, ".local", "state"), "frizz"),
15993
- cache: join3(xdg(env, "XDG_CACHE_HOME") ?? join3(home, ".cache"), "frizz")
15994
- };
15995
- }
15996
- function frizzPaths(options2 = {}) {
15997
- const env = options2.env ?? process.env;
15998
- const platform = options2.platform ?? process.platform;
15999
- const home = options2.home ?? homedir2();
16000
- const exists = options2.exists ?? existsSync2;
16001
- const legacyRoot = legacyFrizzRoot(home);
16002
- if (exists(legacyRoot)) {
16003
- return { data: legacyRoot, state: legacyRoot, cache: legacyRoot, legacy: true };
16004
- }
16005
- const explicit = {
16006
- data: xdg(env, "XDG_DATA_HOME"),
16007
- state: xdg(env, "XDG_STATE_HOME"),
16008
- cache: xdg(env, "XDG_CACHE_HOME")
16009
- };
16010
- const platformRoots = platform === "win32" ? windowsRoots(env, home) : platform === "darwin" ? darwinRoots(home) : xdgRoots(env, home);
16011
- return {
16012
- data: explicit.data ? join3(explicit.data, "frizz") : platformRoots.data,
16013
- state: explicit.state ? join3(explicit.state, "frizz") : platformRoots.state,
16014
- cache: explicit.cache ? join3(explicit.cache, "frizz") : platformRoots.cache,
16015
- legacy: false
16016
- };
16017
- }
16018
- function frizzRoots() {
16019
- return cached ??= frizzPaths();
16020
- }
16021
- function projectStateDir(projectId, home) {
16022
- return join3(home ? frizzPaths({ home }).data : frizzRoots().data, "projects", projectId);
16023
- }
16024
- var LEGACY_DIR_NAME, cached;
16025
- var init_frizz_paths = __esm({
16026
- "packages/server/src/frizz-paths.ts"() {
16027
- "use strict";
16028
- LEGACY_DIR_NAME = ".frizz";
16232
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "2" });
16233
+ res.end(recoveryPage(req.url ?? "/"));
16234
+ return;
16235
+ }
16236
+ const upstream = requestHttp({
16237
+ host: "127.0.0.1",
16238
+ port: childPort,
16239
+ method: req.method,
16240
+ path: req.url,
16241
+ headers: proxyHeaders(req, childPort, this.callerStamp(req), this.vouchesSameOrigin(req))
16242
+ }, (upstreamResponse) => {
16243
+ res.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
16244
+ upstreamResponse.pipe(res);
16245
+ });
16246
+ upstream.once("error", () => {
16247
+ if (res.headersSent) return res.destroy();
16248
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
16249
+ res.end(recoveryPage(req.url ?? "/", "unreachable"));
16250
+ });
16251
+ req.pipe(upstream);
16252
+ }
16253
+ handleUpgrade(req, socket, head) {
16254
+ if (this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
16255
+ rejectWebSocketUpgrade(socket, 401, "Unauthorized");
16256
+ return;
16257
+ }
16258
+ if (!this.authorityAccepted(req, false)) {
16259
+ socket.destroy();
16260
+ return;
16261
+ }
16262
+ const childPort = this.options.childPort();
16263
+ if (!childPort) {
16264
+ socket.destroy();
16265
+ return;
16266
+ }
16267
+ const upstream = connect(childPort, "127.0.0.1");
16268
+ for (const half of [socket, upstream]) {
16269
+ this.upgradedSockets.add(half);
16270
+ half.once("close", () => this.upgradedSockets.delete(half));
16271
+ }
16272
+ upstream.once("connect", () => {
16273
+ const headers = proxyHeaders(req, childPort, this.callerStamp(req));
16274
+ headers.connection = req.headers.connection ?? "Upgrade";
16275
+ const lines = [`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`];
16276
+ for (const [name, value] of Object.entries(headers)) {
16277
+ if (value === void 0) continue;
16278
+ for (const entry of Array.isArray(value) ? value : [value]) lines.push(`${name}: ${entry}`);
16279
+ }
16280
+ upstream.write(`${lines.join("\r\n")}\r
16281
+ \r
16282
+ `);
16283
+ if (head.length > 0) upstream.write(head);
16284
+ socket.pipe(upstream).pipe(socket);
16285
+ });
16286
+ upstream.once("error", () => socket.destroy());
16287
+ socket.once("error", () => upstream.destroy());
16288
+ }
16289
+ };
16029
16290
  }
16030
16291
  });
16031
16292
 
@@ -19723,194 +19984,6 @@ var init_wrapper = __esm({
19723
19984
  }
19724
19985
  });
19725
19986
 
19726
- // packages/server/src/logging.ts
19727
- import {
19728
- appendFileSync,
19729
- mkdirSync as mkdirSync4,
19730
- openSync,
19731
- readdirSync,
19732
- rmSync as rmSync2,
19733
- statSync as statSync2,
19734
- symlinkSync,
19735
- writeFileSync as writeFileSync4,
19736
- writeSync
19737
- } from "node:fs";
19738
- import { homedir as homedir6 } from "node:os";
19739
- import { join as join6 } from "node:path";
19740
- function defaultLogRoot(stateDir, home = homedir6()) {
19741
- return stateDir ? join6(stateDir, "logs") : join6(frizzPaths({ home }).state, "logs");
19742
- }
19743
- function runStamp(at) {
19744
- const pad = (value) => String(value).padStart(2, "0");
19745
- return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}-${pad(at.getMinutes())}-${pad(at.getSeconds())}`;
19746
- }
19747
- function runLogPath(stateDir, at = /* @__PURE__ */ new Date(), pid = process.pid, home = homedir6(), env = process.env) {
19748
- const name = `frizz-${runStamp(at)}-${pid}.log`;
19749
- const override = env[LOG_PATH_ENV]?.trim();
19750
- if (override) return override.endsWith(".log") ? override : join6(override, name);
19751
- return join6(defaultLogRoot(stateDir, home), name);
19752
- }
19753
- function latestLogPath(dir) {
19754
- return join6(dir, "latest.log");
19755
- }
19756
- function linkLatest(dir, target2) {
19757
- const link = latestLogPath(dir);
19758
- try {
19759
- rmSync2(link, { force: true });
19760
- symlinkSync(target2, link);
19761
- } catch {
19762
- try {
19763
- writeFileSync4(link, `${target2}
19764
- `, { mode: 384 });
19765
- } catch {
19766
- }
19767
- }
19768
- }
19769
- function pruneRunLogs(dir, keep = RETAINED_RUNS, days = RETAINED_DAYS, now = Date.now()) {
19770
- let entries;
19771
- try {
19772
- entries = readdirSync(dir).filter((name) => name.endsWith(".log") && name !== "latest.log");
19773
- } catch {
19774
- return;
19775
- }
19776
- const dated = entries.map((name) => {
19777
- try {
19778
- return { name, at: statSync2(join6(dir, name)).mtimeMs };
19779
- } catch {
19780
- return { name, at: 0 };
19781
- }
19782
- }).sort((a, b) => b.at - a.at);
19783
- const cutoff = now - days * 24 * 60 * 60 * 1e3;
19784
- const stale = dated.filter((entry, index) => index >= keep || entry.at < cutoff);
19785
- for (const entry of stale) {
19786
- try {
19787
- rmSync2(join6(dir, entry.name), { force: true });
19788
- } catch {
19789
- }
19790
- }
19791
- }
19792
- function formatDiskLine(record2) {
19793
- const at = new Date(record2.at);
19794
- const pad = (value, width = 2) => String(value).padStart(width, "0");
19795
- const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}.${pad(at.getMilliseconds(), 3)}`;
19796
- return `${clock} ${record2.level.toUpperCase().padEnd(5)} ${record2.scope.padEnd(12)} ${record2.message}
19797
- `;
19798
- }
19799
- function openLogFile(path) {
19800
- try {
19801
- mkdirSync4(join6(path, ".."), { recursive: true, mode: 448 });
19802
- return openSync(path, "a", 384);
19803
- } catch {
19804
- return null;
19805
- }
19806
- }
19807
- function createLogger(options2 = {}) {
19808
- const path = options2.file === null ? null : options2.file ?? runLogPath(options2.stateDir);
19809
- const level = options2.level ?? "debug";
19810
- const now = options2.now ?? Date.now;
19811
- const threshold = LEVEL_ORDER[level];
19812
- const maxBytes = options2.maxBytes ?? MAX_LOG_BYTES;
19813
- let fd = path === null ? null : openLogFile(path);
19814
- if (fd !== null && path !== null && options2.owner !== false) {
19815
- const dir = join6(path, "..");
19816
- pruneRunLogs(dir);
19817
- linkLatest(dir, path);
19818
- }
19819
- let written = 0;
19820
- const listeners = /* @__PURE__ */ new Set();
19821
- const emit = (recordLevel, scope, message) => {
19822
- if (LEVEL_ORDER[recordLevel] < threshold) return;
19823
- const record2 = { at: now(), level: recordLevel, scope, message };
19824
- if (fd !== null) {
19825
- try {
19826
- const line = formatDiskLine(record2);
19827
- if (written + line.length > maxBytes) {
19828
- writeSync(fd, `--- log truncated at ${maxBytes} bytes ---
19829
- `);
19830
- fd = null;
19831
- } else {
19832
- writeSync(fd, line);
19833
- written += line.length;
19834
- }
19835
- } catch {
19836
- fd = null;
19837
- }
19838
- }
19839
- for (const listener of listeners) {
19840
- try {
19841
- listener(record2);
19842
- } catch {
19843
- }
19844
- }
19845
- };
19846
- return {
19847
- debug: (scope, message) => emit("debug", scope, message),
19848
- info: (scope, message) => emit("info", scope, message),
19849
- warn: (scope, message) => emit("warn", scope, message),
19850
- error: (scope, message) => emit("error", scope, message),
19851
- get file() {
19852
- return fd === null ? null : path;
19853
- },
19854
- onRecord(listener) {
19855
- listeners.add(listener);
19856
- return () => listeners.delete(listener);
19857
- },
19858
- close() {
19859
- listeners.clear();
19860
- fd = null;
19861
- }
19862
- };
19863
- }
19864
- function setAmbientLogger(logger2) {
19865
- ambient = logger2;
19866
- return logger2;
19867
- }
19868
- function formatFeedLine(record2) {
19869
- return formatDiskLine(record2).trimEnd();
19870
- }
19871
- function attachTerminalMirror(logger2, enabled = process.env.FRIZZ_DEBUG === "1") {
19872
- if (!enabled) return () => {
19873
- };
19874
- return logger2.onRecord((record2) => process.stderr.write(`${formatFeedLine(record2)}
19875
- `));
19876
- }
19877
- function ambientLogger() {
19878
- if (ambient) return ambient;
19879
- const inherited = process.env[LOG_FILE_ENV] ?? process.env[LOG_PATH_ENV];
19880
- const level = process.env[LOG_LEVEL_ENV];
19881
- const logger2 = createLogger({
19882
- ...inherited ? { file: inherited, owner: false } : { file: null },
19883
- ...level && level in LEVEL_ORDER ? { level } : {}
19884
- });
19885
- attachTerminalMirror(logger2);
19886
- ambient = logger2;
19887
- return ambient;
19888
- }
19889
- function logEnvironment(logger2, level = "debug") {
19890
- return logger2.file ? { [LOG_FILE_ENV]: logger2.file, [LOG_LEVEL_ENV]: level } : {};
19891
- }
19892
- var LEVEL_ORDER, LOG_FILE_ENV, LOG_LEVEL_ENV, LOG_PATH_ENV, RETAINED_RUNS, RETAINED_DAYS, MAX_LOG_BYTES, ambient, log;
19893
- var init_logging = __esm({
19894
- "packages/server/src/logging.ts"() {
19895
- "use strict";
19896
- init_frizz_paths();
19897
- LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
19898
- LOG_FILE_ENV = "FRIZZ_LOG_FILE";
19899
- LOG_LEVEL_ENV = "FRIZZ_LOG_LEVEL";
19900
- LOG_PATH_ENV = "FRIZZ_LOG_PATH";
19901
- RETAINED_RUNS = 20;
19902
- RETAINED_DAYS = 14;
19903
- MAX_LOG_BYTES = 32 * 1024 * 1024;
19904
- ambient = null;
19905
- log = {
19906
- debug: (scope, message) => ambientLogger().debug(scope, message),
19907
- info: (scope, message) => ambientLogger().info(scope, message),
19908
- warn: (scope, message) => ambientLogger().warn(scope, message),
19909
- error: (scope, message) => ambientLogger().error(scope, message)
19910
- };
19911
- }
19912
- });
19913
-
19914
19987
  // packages/server/src/process-generation.ts
19915
19988
  import { execFileSync } from "node:child_process";
19916
19989
  import { randomUUID } from "node:crypto";
@@ -20946,7 +21019,7 @@ var init_boot_progress = __esm({
20946
21019
 
20947
21020
  // packages/server/src/project-root.ts
20948
21021
  import { createHash as createHash3, randomUUID as randomUUID4 } from "node:crypto";
20949
- import { closeSync as closeSync3, existsSync as existsSync4, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
21022
+ import { closeSync as closeSync3, existsSync as existsSync4, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, statSync as statSync5, writeFileSync as writeFileSync8 } from "node:fs";
20950
21023
  import { homedir as homedir8 } from "node:os";
20951
21024
  import { dirname as dirname8, join as join11, parse, resolve as resolve3 } from "node:path";
20952
21025
  function projectIdPath(root) {
@@ -21040,6 +21113,17 @@ function isHomeDirectory(dir, home = homedir8()) {
21040
21113
  return false;
21041
21114
  }
21042
21115
  }
21116
+ function isWorldWritable(dir, platform = process.platform) {
21117
+ if (platform === "win32") return false;
21118
+ try {
21119
+ return (statSync5(dir).mode & 2) !== 0;
21120
+ } catch {
21121
+ return false;
21122
+ }
21123
+ }
21124
+ function refuseWorldWritableProject(dir) {
21125
+ if (isWorldWritable(dir)) throw new Error(`A folder every account can write to cannot be a project: ${dir}`);
21126
+ }
21043
21127
  function existingProjectId(dir) {
21044
21128
  const fromFile = readProjectIdFile(dir);
21045
21129
  if (fromFile) return fromFile;
@@ -23699,27 +23783,27 @@ function createAccessPane(options2) {
23699
23783
  }
23700
23784
 
23701
23785
  // src/remote-controller.ts
23702
- import { homedir as homedir5 } from "node:os";
23786
+ import { homedir as homedir6 } from "node:os";
23703
23787
 
23704
23788
  // src/cloud.ts
23705
23789
  init_frizz_paths();
23706
23790
  init_src();
23707
23791
  import { spawn as spawn2 } from "node:child_process";
23708
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, rmSync } from "node:fs";
23792
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "node:fs";
23709
23793
  import { createInterface } from "node:readline/promises";
23710
- import { homedir as homedir4 } from "node:os";
23711
- import { dirname as dirname4, join as join5 } from "node:path";
23794
+ import { homedir as homedir5 } from "node:os";
23795
+ import { dirname as dirname4, join as join6 } from "node:path";
23712
23796
 
23713
23797
  // src/identity.ts
23714
23798
  init_frizz_paths();
23715
23799
  init_src();
23716
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
23717
- import { homedir as homedir3 } from "node:os";
23718
- import { dirname as dirname3, join as join4 } from "node:path";
23719
- function claimIdentityPath(home = homedir3(), env) {
23720
- return join4(frizzPaths({ home, env }).state, "identity.key");
23800
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
23801
+ import { homedir as homedir4 } from "node:os";
23802
+ import { dirname as dirname3, join as join5 } from "node:path";
23803
+ function claimIdentityPath(home = homedir4(), env) {
23804
+ return join5(frizzPaths({ home, env }).state, "identity.key");
23721
23805
  }
23722
- async function loadOrCreateClaimIdentity(home = homedir3()) {
23806
+ async function loadOrCreateClaimIdentity(home = homedir4()) {
23723
23807
  const path = claimIdentityPath(home);
23724
23808
  let stored;
23725
23809
  try {
@@ -23735,8 +23819,8 @@ async function loadOrCreateClaimIdentity(home = homedir3()) {
23735
23819
  );
23736
23820
  }
23737
23821
  const identity = await generateClaimIdentity();
23738
- mkdirSync2(dirname3(path), { recursive: true });
23739
- writeFileSync2(path, await exportClaimPrivateKey(identity.privateKey), { mode: 384 });
23822
+ mkdirSync3(dirname3(path), { recursive: true });
23823
+ writeFileSync3(path, await exportClaimPrivateKey(identity.privateKey), { mode: 384 });
23740
23824
  return identity;
23741
23825
  }
23742
23826
 
@@ -24332,6 +24416,11 @@ function describeCloudConfig(config) {
24332
24416
  return "Cloudflare Tunnel";
24333
24417
  }
24334
24418
  var REGISTRAR_IS_LIVE = true;
24419
+ var CUSTOM_NAMES_OFFERED = false;
24420
+ function customNamesOffered(env = process.env) {
24421
+ return CUSTOM_NAMES_OFFERED || env.FRIZZ_CUSTOM_NAMES === "1";
24422
+ }
24423
+ var CUSTOM_NAMES_PAUSED = "custom frizz.sh names are paused for now \u2014 press enter for a private name (no account needed), or answer with the hostname of a tunnel you run";
24335
24424
  function isClaimedConfig(config) {
24336
24425
  return typeof config.claim === "string" && config.claim.length > 0;
24337
24426
  }
@@ -24345,19 +24434,24 @@ function zoneClaimLabel(hostname) {
24345
24434
  const label = hostname.slice(0, -suffix.length);
24346
24435
  return label.length > 0 && !label.includes(".") ? label : null;
24347
24436
  }
24348
- async function reconcileCloudConfig(config, port, home = homedir4(), onNotice, origin, github = githubDeviceFlow(onNotice ? { onPrompt: (prompt) => onNotice(deviceCodeMessage(prompt)) } : {})) {
24437
+ async function reconcileCloudConfig(config, port, home = homedir5(), onNotice, origin, github = githubDeviceFlow(onNotice ? { onPrompt: (prompt) => onNotice(deviceCodeMessage(prompt)) } : {})) {
24349
24438
  if (isRelayConfig(config)) return config;
24350
24439
  const label = config.claim ?? zoneClaimLabel(config.hostname);
24351
24440
  if (!label) return config;
24441
+ if (!isAnonymousClaimName(label) && !customNamesOffered()) {
24442
+ throw new Error(
24443
+ `${config.hostname} needs moving onto the Frizz relay, which means claiming it again, and custom frizz.sh names are paused for now \u2014 press R to pick a private name or another setup`
24444
+ );
24445
+ }
24352
24446
  onNotice?.(
24353
24447
  isClaimedConfig(config) ? `${config.hostname} is served by the Frizz relay now; moving this board off its tunnel` : `${config.hostname} is inside ${CLAIM_ZONE}, where names are claimed, not tunnelled; claiming ${label}`
24354
24448
  );
24355
24449
  return establishCloudConfig(label, port, home, origin, github);
24356
24450
  }
24357
- function cloudConfigPath(home = homedir4()) {
24358
- return join5(frizzPaths({ home }).data, "cloud.json");
24451
+ function cloudConfigPath(home = homedir5()) {
24452
+ return join6(frizzPaths({ home }).data, "cloud.json");
24359
24453
  }
24360
- function readCloudConfig(home = homedir4()) {
24454
+ function readCloudConfig(home = homedir5()) {
24361
24455
  try {
24362
24456
  const parsed = JSON.parse(readFileSync3(cloudConfigPath(home), "utf8"));
24363
24457
  if (typeof parsed.hostname !== "string" || !parsed.hostname) return null;
@@ -24377,10 +24471,10 @@ function readCloudConfig(home = homedir4()) {
24377
24471
  return null;
24378
24472
  }
24379
24473
  }
24380
- function tunnelTokenPath(home = homedir4()) {
24381
- return join5(frizzPaths({ home }).state, "tunnel-token");
24474
+ function tunnelTokenPath(home = homedir5()) {
24475
+ return join6(frizzPaths({ home }).state, "tunnel-token");
24382
24476
  }
24383
- function readTunnelToken(home = homedir4()) {
24477
+ function readTunnelToken(home = homedir5()) {
24384
24478
  try {
24385
24479
  const token = readFileSync3(tunnelTokenPath(home), "utf8").trim();
24386
24480
  return token.length > 0 ? token : null;
@@ -24388,19 +24482,19 @@ function readTunnelToken(home = homedir4()) {
24388
24482
  return null;
24389
24483
  }
24390
24484
  }
24391
- function writeTunnelToken(token, home = homedir4()) {
24485
+ function writeTunnelToken(token, home = homedir5()) {
24392
24486
  const path = tunnelTokenPath(home);
24393
- mkdirSync3(dirname4(path), { recursive: true });
24394
- writeFileSync3(path, `${token}
24487
+ mkdirSync4(dirname4(path), { recursive: true });
24488
+ writeFileSync4(path, `${token}
24395
24489
  `, { mode: 384 });
24396
24490
  }
24397
- function deleteCloudConfig(home = homedir4()) {
24398
- rmSync(cloudConfigPath(home), { force: true });
24491
+ function deleteCloudConfig(home = homedir5()) {
24492
+ rmSync2(cloudConfigPath(home), { force: true });
24399
24493
  }
24400
- function writeCloudConfig(config, home = homedir4()) {
24494
+ function writeCloudConfig(config, home = homedir5()) {
24401
24495
  const path = cloudConfigPath(home);
24402
- mkdirSync3(dirname4(path), { recursive: true });
24403
- writeFileSync3(path, `${JSON.stringify(config, null, 2)}
24496
+ mkdirSync4(dirname4(path), { recursive: true });
24497
+ writeFileSync4(path, `${JSON.stringify(config, null, 2)}
24404
24498
  `);
24405
24499
  }
24406
24500
  function normalizeHostname(raw) {
@@ -24420,7 +24514,7 @@ async function promptForTunnelName(hostname, fallback) {
24420
24514
  rl.close();
24421
24515
  }
24422
24516
  }
24423
- async function establishCloudConfig(answer, port, home = homedir4(), origin, github = githubDeviceFlow()) {
24517
+ async function establishCloudConfig(answer, port, home = homedir5(), origin, github = githubDeviceFlow()) {
24424
24518
  if (answer.includes(".")) {
24425
24519
  const hostname = normalizeHostname(answer);
24426
24520
  const tunnel = await promptForTunnelName(hostname, hostname.split(".")[0]);
@@ -24432,6 +24526,7 @@ async function establishCloudConfig(answer, port, home = homedir4(), origin, git
24432
24526
  );
24433
24527
  }
24434
24528
  const anonymous = answer === "" || isAnonymousClaimName(answer);
24529
+ if (!anonymous && !customNamesOffered()) throw new Error(CUSTOM_NAMES_PAUSED);
24435
24530
  if (!anonymous && !claimNameIsValid(answer)) {
24436
24531
  normalizeClaimName(answer);
24437
24532
  }
@@ -24464,7 +24559,7 @@ async function establishCloudConfig(answer, port, home = homedir4(), origin, git
24464
24559
  }
24465
24560
  return { hostname: result.hostname, claim: name, serve: "relay" };
24466
24561
  }
24467
- async function resolveRunToken(config, port, home = homedir4(), onWarning, origin) {
24562
+ async function resolveRunToken(config, port, home = homedir5(), onWarning, origin) {
24468
24563
  if (!isClaimedConfig(config)) return null;
24469
24564
  const cached2 = readTunnelToken(home);
24470
24565
  try {
@@ -24481,12 +24576,12 @@ async function resolveRunToken(config, port, home = homedir4(), onWarning, origi
24481
24576
  throw error;
24482
24577
  }
24483
24578
  }
24484
- function resolveTunnelConfigPath(config, home = homedir4()) {
24579
+ function resolveTunnelConfigPath(config, home = homedir5()) {
24485
24580
  if (config.config) return config.config;
24486
- const candidate = join5(home, ".cloudflared", "frizz.yml");
24581
+ const candidate = join6(home, ".cloudflared", "frizz.yml");
24487
24582
  return existsSync3(candidate) ? candidate : null;
24488
24583
  }
24489
- async function startRelay(config, port, home = homedir4(), onStatus) {
24584
+ async function startRelay(config, port, home = homedir5(), onStatus) {
24490
24585
  const identity = await loadOrCreateClaimIdentity(home);
24491
24586
  const origin = `https://${config.hostname}`;
24492
24587
  return connectRelay({
@@ -24504,7 +24599,7 @@ async function startRelay(config, port, home = homedir4(), onStatus) {
24504
24599
  } : {}
24505
24600
  });
24506
24601
  }
24507
- function startTunnel(config, onExit, onError, home = homedir4(), runToken) {
24602
+ function startTunnel(config, onExit, onError, home = homedir5(), runToken) {
24508
24603
  const configPath = runToken ? null : resolveTunnelConfigPath(config, home);
24509
24604
  const args2 = runToken ? ["tunnel", "--no-autoupdate", "run", "--token", runToken] : [
24510
24605
  "tunnel",
@@ -24535,7 +24630,7 @@ function startTunnel(config, onExit, onError, home = homedir4(), runToken) {
24535
24630
 
24536
24631
  // src/remote-controller.ts
24537
24632
  function createRemoteController(options2) {
24538
- const home = options2.home ?? homedir5();
24633
+ const home = options2.home ?? homedir6();
24539
24634
  let config = null;
24540
24635
  let transport = null;
24541
24636
  const stopTransport = () => {
@@ -24650,6 +24745,15 @@ var CHOICES = [
24650
24745
  { kind: "other", title: "Something else", blurb: "any proxy or tunnel you run \u2014 tell Frizz its address" },
24651
24746
  { kind: "off", title: "Off", blurb: "loopback only" }
24652
24747
  ];
24748
+ function choicesFor(current) {
24749
+ if (customNamesOffered()) return CHOICES;
24750
+ if (kindOf(current) === "frizz") {
24751
+ return CHOICES.map(
24752
+ (choice) => choice.kind === "frizz" ? { ...choice, blurb: `${current.hostname} \u2014 kept and renewed; new custom names are paused` } : choice
24753
+ );
24754
+ }
24755
+ return CHOICES.filter((choice) => choice.kind !== "frizz");
24756
+ }
24653
24757
  var ENTER = /* @__PURE__ */ new Set(["\r", "\n"]);
24654
24758
  var BACKSPACE = /* @__PURE__ */ new Set(["\x7F", "\b"]);
24655
24759
  var ESC = "\x1B";
@@ -24690,6 +24794,8 @@ function createRemotePane(options2) {
24690
24794
  output.write(fitScreen([{ text: "", drop: 0 }, ...indented], output.rows, output.columns));
24691
24795
  };
24692
24796
  const check = (ok, text) => ok ? `${text} \u2713` : text;
24797
+ const choices = () => choicesFor(options2.current());
24798
+ const rowOf = (kind) => Math.max(0, choices().findIndex((choice) => choice.kind === kind));
24693
24799
  const paint = () => {
24694
24800
  if (!open) return;
24695
24801
  const s = screen;
@@ -24702,7 +24808,7 @@ function createRemotePane(options2) {
24702
24808
  "Frizz binds 127.0.0.1 and has no login. Reaching it from another device means something in front of it does the authenticating. Whatever you pick here is remembered; from then on a plain `npx frizz` serves it."
24703
24809
  ),
24704
24810
  "",
24705
- ...CHOICES.map((choice, index) => {
24811
+ ...choices().map((choice, index) => {
24706
24812
  const marker = index === s.index ? "\u276F" : " ";
24707
24813
  const title = choice.title.padEnd(18);
24708
24814
  const now = choice.kind === current ? ` ${DIM}(current)${RESET2}` : "";
@@ -24911,6 +25017,21 @@ function createRemotePane(options2) {
24911
25017
  }
24912
25018
  paint();
24913
25019
  };
25020
+ const serveCurrent = async () => {
25021
+ const current = options2.current();
25022
+ const back = { name: "menu", index: rowOf("frizz") };
25023
+ if (!current) return;
25024
+ screen = { name: "busy", message: `serving ${current.hostname}\u2026` };
25025
+ paint();
25026
+ try {
25027
+ await options2.apply(current);
25028
+ options2.onChanged?.(current);
25029
+ screen = { name: "done", message: `Serving https://${current.hostname} (${describeCloudConfig(current)}).`, link: options2.issueLink(), config: current };
25030
+ } catch (error) {
25031
+ screen = { name: "error", message: error instanceof Error ? error.message : String(error), back };
25032
+ }
25033
+ paint();
25034
+ };
24914
25035
  const turnOff = async () => {
24915
25036
  screen = { name: "busy", message: "back to loopback only\u2026" };
24916
25037
  paint();
@@ -24919,15 +25040,14 @@ function createRemotePane(options2) {
24919
25040
  options2.onChanged?.(null);
24920
25041
  screen = { name: "done", message: "Loopback only. This board is reachable from this machine alone.", link: null, config: null };
24921
25042
  } catch (error) {
24922
- screen = { name: "error", message: error instanceof Error ? error.message : String(error), back: { name: "menu", index: CHOICES.length - 1 } };
25043
+ screen = { name: "error", message: error instanceof Error ? error.message : String(error), back: { name: "menu", index: rowOf("off") } };
24923
25044
  }
24924
25045
  paint();
24925
25046
  };
24926
25047
  return {
24927
25048
  open() {
24928
25049
  open = true;
24929
- const current = kindOf(options2.current());
24930
- screen = { name: "menu", index: Math.max(0, CHOICES.findIndex((choice) => choice.kind === current)) };
25050
+ screen = { name: "menu", index: rowOf(kindOf(options2.current())) };
24931
25051
  output.write(ALT_SCREEN_ON);
24932
25052
  output.write(HIDE_CURSOR);
24933
25053
  paint();
@@ -24950,13 +25070,15 @@ function createRemotePane(options2) {
24950
25070
  }
24951
25071
  if (s.name === "menu") {
24952
25072
  if (key === ESC || key === "q") return "close";
24953
- if (key === UP || key === "k") s.index = (s.index + CHOICES.length - 1) % CHOICES.length;
24954
- else if (key === DOWN || key === "j") s.index = (s.index + 1) % CHOICES.length;
24955
- else if (/^[1-6]$/.test(key)) s.index = Number(key) - 1;
25073
+ const rows = choices();
25074
+ if (key === UP || key === "k") s.index = (s.index + rows.length - 1) % rows.length;
25075
+ else if (key === DOWN || key === "j") s.index = (s.index + 1) % rows.length;
25076
+ else if (/^[1-9]$/.test(key) && Number(key) <= rows.length) s.index = Number(key) - 1;
24956
25077
  else if (ENTER.has(key)) {
24957
- const choice = CHOICES[s.index];
25078
+ const choice = rows[Math.min(s.index, rows.length - 1)];
24958
25079
  if (choice.kind === "off") void turnOff();
24959
25080
  else if (choice.kind === "private") void claimPrivate();
25081
+ else if (choice.kind === "frizz" && !customNamesOffered()) void serveCurrent();
24960
25082
  else openForm(choice.kind);
24961
25083
  return "keep";
24962
25084
  }
@@ -24964,7 +25086,7 @@ function createRemotePane(options2) {
24964
25086
  return "keep";
24965
25087
  }
24966
25088
  if (key === ESC) {
24967
- screen = { name: "menu", index: CHOICES.findIndex((choice) => choice.kind === s.kind) };
25089
+ screen = { name: "menu", index: rowOf(s.kind) };
24968
25090
  paint();
24969
25091
  return "keep";
24970
25092
  }
@@ -25352,7 +25474,7 @@ var LAUNCH_TIMEOUT_MS = BOOT_STALL_TIMEOUT_MS;
25352
25474
  var LAUNCH_HARD_TIMEOUT_MS = BOOT_HARD_TIMEOUT_MS;
25353
25475
  var RETIRED_NETWORK_FLAGS = ["--host", "--allowed-host", "--public-origin", "--cloud"];
25354
25476
  function retiredFlagMessage(flag) {
25355
- return `${flag} was retired \u2014 how a board is reached is set up from the running board now: press R in its terminal to pick a private or custom frizz.sh name, a Cloudflare Tunnel, Tailscale or a proxy of your own. The choice is remembered, so a plain launch serves it.`;
25477
+ return `${flag} was retired \u2014 how a board is reached is set up from the running board now: press R in its terminal to pick a private frizz.sh name, a Cloudflare Tunnel, Tailscale or a proxy of your own. The choice is remembered, so a plain launch serves it.`;
25356
25478
  }
25357
25479
  function parseCliArgs(argv) {
25358
25480
  const args2 = new Set(argv);
@@ -25521,16 +25643,17 @@ function cleanupSandbox(home) {
25521
25643
  function resolveLaunchIntent(cwd = process.cwd(), home = homedir10(), env = process.env) {
25522
25644
  const candidate = realpathSync5(discoverProjectRoot(cwd, home));
25523
25645
  const known = isExistingProjectRoot(candidate) || findByPath(candidate, home) !== void 0;
25524
- if ((known || hasProjectMarker(candidate)) && !isHomeDirectory(candidate, home))
25646
+ const refused = isHomeDirectory(candidate, home) ? "home" : isWorldWritable(candidate) ? "world-writable" : void 0;
25647
+ if ((known || hasProjectMarker(candidate)) && !refused)
25525
25648
  return { kind: "open", workspace: resolveWorkspace(cwd, home, env) };
25526
25649
  const host = mostRecentProject(home, env);
25527
- const reason = isHomeDirectory(candidate, home) ? "home" : "unadopted";
25650
+ const reason = refused ?? "unadopted";
25528
25651
  if (!host) return { kind: "empty", directory: candidate, reason };
25529
- return reason === "home" ? { kind: "grid", workspace: host } : { kind: "offer", workspace: host, directory: candidate };
25652
+ return reason === "unadopted" ? { kind: "offer", workspace: host, directory: candidate } : { kind: "grid", workspace: host };
25530
25653
  }
25531
25654
  function mostRecentProject(home, env) {
25532
25655
  for (const entry of listProjects(home)) {
25533
- if (entry.stale || isHomeDirectory(entry.path, home)) continue;
25656
+ if (entry.stale || isHomeDirectory(entry.path, home) || isWorldWritable(entry.path)) continue;
25534
25657
  try {
25535
25658
  return resolveWorkspace(entry.path, home, env);
25536
25659
  } catch {
@@ -25553,6 +25676,7 @@ function resolveWorkspace(cwd = process.cwd(), home = homedir10(), env = process
25553
25676
  gitRoot = void 0;
25554
25677
  }
25555
25678
  const root0 = realpathSync5(gitRoot ?? discoverProjectRoot(cwd, home));
25679
+ refuseWorldWritableProject(root0);
25556
25680
  const identity = gitRoot ? resolveGitProjectIdentity(root0, home) : void 0;
25557
25681
  const root = identity?.root ?? root0;
25558
25682
  const id = ensureProjectIdFile(root, home, identity?.id);
@@ -27094,7 +27218,7 @@ import {
27094
27218
  realpathSync as realpathSync6,
27095
27219
  renameSync as renameSync6,
27096
27220
  rmSync as rmSync9,
27097
- statSync as statSync5,
27221
+ statSync as statSync6,
27098
27222
  writeFileSync as writeFileSync11
27099
27223
  } from "node:fs";
27100
27224
  import { delimiter as delimiter2, dirname as dirname13, isAbsolute as isAbsolute6, join as join15, relative as relative3, resolve as resolve6, sep as sep4 } from "node:path";
@@ -27138,7 +27262,7 @@ function containedFile(root, name, directory = false) {
27138
27262
  const rel = relative3(realpathSync6(root), path);
27139
27263
  if (rel === ".." || rel.startsWith(`..${sep4}`) || isAbsolute6(rel))
27140
27264
  throw new Error(`Frizz server artifact escapes its package: ${name}`);
27141
- const stat = statSync5(path);
27265
+ const stat = statSync6(path);
27142
27266
  if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error(`invalid Frizz server artifact: ${name}`);
27143
27267
  return path;
27144
27268
  }
@@ -27181,7 +27305,7 @@ function resolveNpmCli(env = process.env, executable = process.execPath) {
27181
27305
  } catch {
27182
27306
  }
27183
27307
  }
27184
- const found = candidates.find((path) => existsSync9(path) && statSync5(path).isFile());
27308
+ const found = candidates.find((path) => existsSync9(path) && statSync6(path).isFile());
27185
27309
  if (!found) throw new Error("npm was not found beside Node or on PATH; install Node with npm to install Frizz server releases");
27186
27310
  return resolve6(found);
27187
27311
  }
@@ -27563,7 +27687,7 @@ Options:
27563
27687
 
27564
27688
 
27565
27689
  To reach the board from a phone or another machine, press R in the terminal running it: a short
27566
- walkthrough sets up a private frizz.sh name (no account needed), a custom one, a Cloudflare
27690
+ walkthrough sets up a private frizz.sh name (no account needed), a Cloudflare
27567
27691
  Tunnel, Tailscale, or a proxy of your own, and
27568
27692
  remembers the choice, so a plain launch serves it from then on. The board stays on loopback and
27569
27693
  shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.`
@@ -27598,7 +27722,7 @@ var workspace = (() => {
27598
27722
  const intent = resolveLaunchIntent();
27599
27723
  if (intent.kind === "empty")
27600
27724
  throw new Error(
27601
- intent.reason === "home" ? "frizz cannot open your home directory as a project, and there is no other project to show yet. cd into a repository and run frizz there." : `${intent.directory} is not a Frizz project yet, and there is no other project to show. Run frizz inside a repository, or add this one from the projects page once a board is open.`
27725
+ intent.reason === "home" ? "frizz cannot open your home directory as a project, and there is no other project to show yet. cd into a repository and run frizz there." : intent.reason === "world-writable" ? `A folder every account can write to cannot be a project: ${intent.directory}. Run frizz in a folder only you can write to.` : `${intent.directory} is not a Frizz project yet, and there is no other project to show. Run frizz inside a repository, or add this one from the projects page once a board is open.`
27602
27726
  );
27603
27727
  launchIntent = intent;
27604
27728
  return intent.workspace;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frizz",
3
- "version": "0.13.8",
3
+ "version": "0.13.9",
4
4
  "private": false,
5
5
  "description": "A local web UI for running many coding agents at once",
6
6
  "keywords": [
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "frizzServer": {
68
68
  "package": "frizz-server",
69
- "version": "0.15.17",
69
+ "version": "0.15.18",
70
70
  "protocol": 1,
71
71
  "dataEpoch": 1
72
72
  }