frizz 0.7.7 → 0.7.8

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 (95) hide show
  1. package/README.md +11 -46
  2. package/dist/dev-child.js +201 -63
  3. package/dist/frizz.js +918 -398
  4. package/package.json +1 -1
  5. package/runtime/cc-worker/DECISIONS.md +2 -1
  6. package/runtime/cc-worker/bin/frizz-mcp.mjs +3 -2
  7. package/web-dist/assets/{TerminalPane-Cb8jOFP9.js → TerminalPane-ubcEP7X3.js} +1 -1
  8. package/web-dist/assets/{abnfDiagram-VRR7QNED-Dtbg2eZ0.js → abnfDiagram-VRR7QNED-0brfnieb.js} +1 -1
  9. package/web-dist/assets/architecture-TIHT7OUA-DksosNDW.js +1 -0
  10. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-DzQjWW7u.js → architectureDiagram-ZJ3FMSHR-Dji-XBdF.js} +1 -1
  11. package/web-dist/assets/{blockDiagram-677ZJIJ3-BJnpkLMt.js → blockDiagram-677ZJIJ3-BQMyz3Q9.js} +1 -1
  12. package/web-dist/assets/{c4Diagram-LMCZKHZV-COdatsYu.js → c4Diagram-LMCZKHZV-BMNws0Oi.js} +1 -1
  13. package/web-dist/assets/channel-DqoPvfQz.js +1 -0
  14. package/web-dist/assets/{chunk-32BRIVSS-CORMw4Vm.js → chunk-32BRIVSS-CVv1ae7n.js} +1 -1
  15. package/web-dist/assets/{chunk-52WLFC77-C7lEadTg.js → chunk-52WLFC77-BYhPlKCT.js} +1 -1
  16. package/web-dist/assets/{chunk-C7G6YPKG-DZy4F97l.js → chunk-C7G6YPKG-Cn4HhNwb.js} +1 -1
  17. package/web-dist/assets/{chunk-EX3LRPZG-HynlfV9p.js → chunk-EX3LRPZG-P3ZYFC1W.js} +1 -1
  18. package/web-dist/assets/{chunk-FWX5IMBZ-BbwKNhKm.js → chunk-FWX5IMBZ-Ba3NpEQa.js} +2 -2
  19. package/web-dist/assets/{chunk-HOUHSVGY-lIhOZSmL.js → chunk-HOUHSVGY-BCWyMhwJ.js} +1 -1
  20. package/web-dist/assets/{chunk-ICXQ74PX-WGxaMO8c.js → chunk-ICXQ74PX-DWAAqHL7.js} +1 -1
  21. package/web-dist/assets/{chunk-MOJQB5TN-B-cx0f3u.js → chunk-MOJQB5TN-pc5VJ4Nq.js} +1 -1
  22. package/web-dist/assets/{chunk-OGEWGWER-DJceVwZH.js → chunk-OGEWGWER-DUxmbiuX.js} +1 -1
  23. package/web-dist/assets/{chunk-PUDLZKDR-CGwHZns8.js → chunk-PUDLZKDR-DRdZWzxX.js} +1 -1
  24. package/web-dist/assets/{chunk-Q4XR5HBZ-PTSWtmk1.js → chunk-Q4XR5HBZ-Bafzu7s-.js} +1 -1
  25. package/web-dist/assets/{chunk-V7JOEXUC-VNxHZPcf.js → chunk-V7JOEXUC-Bj0A8ftW.js} +1 -1
  26. package/web-dist/assets/{chunk-VAUOI2AC-ZXYCz_Aw.js → chunk-VAUOI2AC-DBgVQKIO.js} +1 -1
  27. package/web-dist/assets/{chunk-VR4S4FIN-9WCtmFJp.js → chunk-VR4S4FIN-DU-1KtuM.js} +1 -1
  28. package/web-dist/assets/{chunk-WYO6CB5R-V4EBjpmt.js → chunk-WYO6CB5R-DzVb3-Rj.js} +1 -1
  29. package/web-dist/assets/{chunk-ZGVPDNZ5-CPW0Z0ae.js → chunk-ZGVPDNZ5-BPKQvduj.js} +1 -1
  30. package/web-dist/assets/classDiagram-OUVF2IWQ-DwUs74RB.js +1 -0
  31. package/web-dist/assets/classDiagram-v2-EOCWNBFH-DwUs74RB.js +1 -0
  32. package/web-dist/assets/{cynefin-VYW2F7L2-E7PklfJD.js → cynefin-VYW2F7L2-lo8PqVCk.js} +1 -1
  33. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-JIXAPjDf.js → cynefinDiagram-TSTJHNR4-BtQLaDV0.js} +1 -1
  34. package/web-dist/assets/{dagre-VKFMJZFB-DjXrdcNx.js → dagre-VKFMJZFB-BJ2-f-A0.js} +1 -1
  35. package/web-dist/assets/{diagram-FQU43EPY--i9i0qhm.js → diagram-FQU43EPY-CLM-8r74.js} +1 -1
  36. package/web-dist/assets/{diagram-G47NLZAW-DxgcENZ_.js → diagram-G47NLZAW-CfuoJGpN.js} +1 -1
  37. package/web-dist/assets/{diagram-NH7WQ7WH-CUy9Nr90.js → diagram-NH7WQ7WH-DfxxRwH8.js} +1 -1
  38. package/web-dist/assets/{diagram-OA4YK3LP-DbTbUaCb.js → diagram-OA4YK3LP-D7knxZee.js} +1 -1
  39. package/web-dist/assets/{diagram-WEI45ONY-2gk6xhOq.js → diagram-WEI45ONY-C2WfMXWl.js} +1 -1
  40. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-uE2iDETg.js → ebnfDiagram-CCIWWBDH-DRSZ24tF.js} +1 -1
  41. package/web-dist/assets/{erDiagram-Q63AITRT-dztd4MUw.js → erDiagram-Q63AITRT-DXe0OrbY.js} +1 -1
  42. package/web-dist/assets/eventmodeling-45OFAUF4-CUIjQ7kE.js +1 -0
  43. package/web-dist/assets/flowDiagram-23GEKE2U-Dka-3F3n.js +1 -0
  44. package/web-dist/assets/{ganttDiagram-NO4QXBWP-DAds6Aht.js → ganttDiagram-NO4QXBWP-wGqOgjvs.js} +1 -1
  45. package/web-dist/assets/{gitGraph-TEB2WS4Q-Ci0XVvIa.js → gitGraph-TEB2WS4Q-BygokG6y.js} +1 -1
  46. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-C6FULyt8.js → gitGraphDiagram-IHSO6WYX-CV1933_M.js} +1 -1
  47. package/web-dist/assets/index-CmrDpsD8.css +1 -0
  48. package/web-dist/assets/{index-CNy5uOpI.js → index-DB1d3eMJ.js} +75 -75
  49. package/web-dist/assets/{info-DKCQHKI2-9N77dOiw.js → info-DKCQHKI2-DLavbzui.js} +1 -1
  50. package/web-dist/assets/{infoDiagram-FWYZ7A6U-BymP3PgG.js → infoDiagram-FWYZ7A6U-Biouxd9G.js} +1 -1
  51. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-CU_kQBz1.js → ishikawaDiagram-FXEZZL3T-DJxwwS3R.js} +1 -1
  52. package/web-dist/assets/{journeyDiagram-5HDEW3XC-DZN15HSd.js → journeyDiagram-5HDEW3XC-Clx_WkdW.js} +1 -1
  53. package/web-dist/assets/{kanban-definition-HUTT4EX6-J4wR5g_3.js → kanban-definition-HUTT4EX6-Bf7bX_96.js} +1 -1
  54. package/web-dist/assets/{line-SRzGpiDr.js → line-CV2v49IN.js} +1 -1
  55. package/web-dist/assets/{mermaid-parser.core-CGBscNGo.js → mermaid-parser.core-BPsFhEi6.js} +3 -3
  56. package/web-dist/assets/{mermaid.core-xNdwXb1r.js → mermaid.core-0EWDNL_R.js} +3 -3
  57. package/web-dist/assets/{mindmap-definition-LN4V7U3C-gR2dbzrj.js → mindmap-definition-LN4V7U3C-C1y4yy3Z.js} +1 -1
  58. package/web-dist/assets/{packet-7NZHBO7P-DrR4W6I3.js → packet-7NZHBO7P-CpcpxruB.js} +1 -1
  59. package/web-dist/assets/{pegDiagram-2B236MQR-DN7R-_t4.js → pegDiagram-2B236MQR-CVlVP4cH.js} +1 -1
  60. package/web-dist/assets/{pie-RZYD4A2V-C8VjQAFZ.js → pie-RZYD4A2V-BcVPOhTZ.js} +1 -1
  61. package/web-dist/assets/{pieDiagram-ENE6RG2P-5w64zdD1.js → pieDiagram-ENE6RG2P-DZv4Tfae.js} +1 -1
  62. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-B4rbezq_.js → quadrantDiagram-ABIIQ3AL-DOzS_U_a.js} +1 -1
  63. package/web-dist/assets/{radar-I7S5WNFK-CTbbPB8m.js → radar-I7S5WNFK-8YADxkdT.js} +1 -1
  64. package/web-dist/assets/{railroad-3IZDKUUU-CY1er15b.js → railroad-3IZDKUUU-DzndPrRy.js} +1 -1
  65. package/web-dist/assets/railroad-abnf-AHOZXSZD-CCnWa6K9.js +1 -0
  66. package/web-dist/assets/railroad-ebnf-EBAXGLYW-jtVdzv7e.js +1 -0
  67. package/web-dist/assets/railroad-peg-LSFZ7HO6-B8D6TyEe.js +1 -0
  68. package/web-dist/assets/{railroadDiagram-RFXS5EU6-Bx2HJ3yT.js → railroadDiagram-RFXS5EU6-CPJAH-r7.js} +1 -1
  69. package/web-dist/assets/{requirementDiagram-TGXJPOKE-BLK-Y2rx.js → requirementDiagram-TGXJPOKE-BVFJZfwl.js} +1 -1
  70. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-Dk-8Miv-.js → sankeyDiagram-HTMAVEWB-CUhvFPmE.js} +1 -1
  71. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-DL2alfR-.js → sequenceDiagram-DBY2YBRQ-Cy9CGhNq.js} +1 -1
  72. package/web-dist/assets/{stateDiagram-2N3HPSRC-Bka44wf1.js → stateDiagram-2N3HPSRC-CqOnmPLp.js} +1 -1
  73. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-DWKW-4qu.js +1 -0
  74. package/web-dist/assets/{swimlanes-5IMT3BWC-e0GbMwIr.js → swimlanes-5IMT3BWC-c_zBqkb9.js} +1 -1
  75. package/web-dist/assets/swimlanesDiagram-G3AALYLV-DZKoB7ae.js +8 -0
  76. package/web-dist/assets/{timeline-definition-FHXFAJF6-CT3GFq89.js → timeline-definition-FHXFAJF6-y_QM62SK.js} +1 -1
  77. package/web-dist/assets/{treeView-QDETBFTQ-BJauJ0bm.js → treeView-QDETBFTQ-T3y_1KXV.js} +1 -1
  78. package/web-dist/assets/{treemap-6X3UGDF4-DCX7A-nx.js → treemap-6X3UGDF4-CzvqucqY.js} +1 -1
  79. package/web-dist/assets/{vennDiagram-L72KCM5P-D4dm2iTR.js → vennDiagram-L72KCM5P-6-HNOpN0.js} +1 -1
  80. package/web-dist/assets/{wardley-OPB4EBWU-DklTDSId.js → wardley-OPB4EBWU-BrsaqsLL.js} +1 -1
  81. package/web-dist/assets/{wardleyDiagram-EHGQE667-CikP5hBd.js → wardleyDiagram-EHGQE667-Cr_rCzwd.js} +1 -1
  82. package/web-dist/assets/{xychartDiagram-FW5EYKEG-FXuOAFpz.js → xychartDiagram-FW5EYKEG-BnX6ccx4.js} +1 -1
  83. package/web-dist/index.html +2 -2
  84. package/web-dist/assets/architecture-TIHT7OUA-CTxVpl9_.js +0 -1
  85. package/web-dist/assets/channel-COS0Ygbp.js +0 -1
  86. package/web-dist/assets/classDiagram-OUVF2IWQ-Df5TLnrN.js +0 -1
  87. package/web-dist/assets/classDiagram-v2-EOCWNBFH-Df5TLnrN.js +0 -1
  88. package/web-dist/assets/eventmodeling-45OFAUF4-DPVPOEaJ.js +0 -1
  89. package/web-dist/assets/flowDiagram-23GEKE2U-BMIMgVYd.js +0 -1
  90. package/web-dist/assets/index-D6LsViUY.css +0 -1
  91. package/web-dist/assets/railroad-abnf-AHOZXSZD-DfEpkrtJ.js +0 -1
  92. package/web-dist/assets/railroad-ebnf-EBAXGLYW-Csunejgu.js +0 -1
  93. package/web-dist/assets/railroad-peg-LSFZ7HO6-BgRslbM_.js +0 -1
  94. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-6KcScVPX.js +0 -1
  95. package/web-dist/assets/swimlanesDiagram-G3AALYLV-jaJhlDbE.js +0 -8
package/dist/frizz.js CHANGED
@@ -140,52 +140,9 @@ function isTrustedLocalHttpRequest(headers, expectedPort, allowMissingOrigin = f
140
140
  function allowedLocalCorsOrigin(origin, expectedPort, policy) {
141
141
  return parseLocalHttpOrigin(origin, expectedPort, policy) ? origin : void 0;
142
142
  }
143
- function normalizeBindHost(value) {
144
- const trimmed = value.trim();
145
- if (!trimmed) throw new Error("--host requires an address");
146
- const bare = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
147
- const lower = bare.toLowerCase();
148
- if (lower === "localhost") return LOOPBACK_BIND_HOST;
149
- if (isIP(bare) === 0) {
150
- throw new Error(`invalid --host address: ${value} (use an IP address such as 0.0.0.0, :: or 127.0.0.1)`);
151
- }
152
- return bare;
153
- }
154
143
  function bindHostIsExposed(host) {
155
144
  return !LOOPBACK_BIND_HOSTS.has(host.trim().toLowerCase());
156
145
  }
157
- function normalizePublicOrigin(value) {
158
- const trimmed = value.trim();
159
- const invalid = (why) => new Error(`invalid --public-origin: ${value} (${why})`);
160
- if (!trimmed) throw new Error("--public-origin requires a URL");
161
- let url;
162
- try {
163
- url = new URL(trimmed);
164
- } catch {
165
- throw invalid("use a full URL such as https://frizz.example.com");
166
- }
167
- if (url.protocol !== "https:" && url.protocol !== "http:") throw invalid("only http and https are supported");
168
- if (url.username || url.password) throw invalid("an origin carries no credentials");
169
- if (url.pathname !== "/" || url.search || url.hash) throw invalid("an origin is a scheme and host only, with no path");
170
- if (!url.hostname) throw invalid("no hostname");
171
- return url.origin;
172
- }
173
- function machineHostNames(hostname) {
174
- const full = hostname.trim().toLowerCase().replace(/\.$/, "");
175
- if (!full || full === "localhost" || isIP(full) !== 0) return [];
176
- const short = full.split(".")[0];
177
- return [.../* @__PURE__ */ new Set([full, short, `${short}.local`])].filter((name) => name !== "localhost" && name !== "localhost.local");
178
- }
179
- function normalizeAllowedHosts(values) {
180
- const seen = /* @__PURE__ */ new Set();
181
- for (const value of values) {
182
- for (const entry of value.split(",")) {
183
- const host = entry.trim().toLowerCase();
184
- if (host) seen.add(host);
185
- }
186
- }
187
- return [...seen];
188
- }
189
146
  function rejectWebSocketUpgrade(socket, status = 403, reason = "Forbidden") {
190
147
  const body = `${reason}
191
148
  `;
@@ -206,7 +163,7 @@ function rejectWebSocketUpgrade(socket, status = 403, reason = "Forbidden") {
206
163
  }
207
164
  }
208
165
  }
209
- var LOCAL_HOSTNAMES, FORWARDED_HEADERS, LOOPBACK_BIND_HOST, ALL_INTERFACES_BIND_HOST, LOOPBACK_BIND_HOSTS;
166
+ var LOCAL_HOSTNAMES, FORWARDED_HEADERS, LOOPBACK_BIND_HOST, LOOPBACK_BIND_HOSTS;
210
167
  var init_local_origin = __esm({
211
168
  "packages/server/src/local-origin.ts"() {
212
169
  "use strict";
@@ -219,7 +176,6 @@ var init_local_origin = __esm({
219
176
  "x-forwarded-proto"
220
177
  ];
221
178
  LOOPBACK_BIND_HOST = "127.0.0.1";
222
- ALL_INTERFACES_BIND_HOST = "0.0.0.0";
223
179
  LOOPBACK_BIND_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost", "[::1]"]);
224
180
  }
225
181
  });
@@ -17842,9 +17798,9 @@ var init_wrapper = __esm({
17842
17798
 
17843
17799
  // packages/server/src/frizz-paths.ts
17844
17800
  import { existsSync as existsSync2 } from "node:fs";
17845
- import { homedir as homedir4, tmpdir } from "node:os";
17801
+ import { homedir as homedir5, tmpdir } from "node:os";
17846
17802
  import { dirname as dirname4, join as join5 } from "node:path";
17847
- function legacyFrizzRoot(home = homedir4()) {
17803
+ function legacyFrizzRoot(home = homedir5()) {
17848
17804
  return join5(home, LEGACY_DIR_NAME);
17849
17805
  }
17850
17806
  function xdg(env, name) {
@@ -17886,7 +17842,7 @@ function xdgRoots(env, home) {
17886
17842
  function frizzPaths(options2 = {}) {
17887
17843
  const env = options2.env ?? process.env;
17888
17844
  const platform = options2.platform ?? process.platform;
17889
- const home = options2.home ?? homedir4();
17845
+ const home = options2.home ?? homedir5();
17890
17846
  const exists = options2.exists ?? existsSync2;
17891
17847
  const legacyRoot = legacyFrizzRoot(home);
17892
17848
  if (exists(legacyRoot)) {
@@ -17925,22 +17881,22 @@ import {
17925
17881
  mkdirSync as mkdirSync4,
17926
17882
  openSync,
17927
17883
  readdirSync,
17928
- rmSync,
17884
+ rmSync as rmSync2,
17929
17885
  statSync as statSync2,
17930
17886
  symlinkSync,
17931
17887
  writeFileSync as writeFileSync4,
17932
17888
  writeSync
17933
17889
  } from "node:fs";
17934
- import { homedir as homedir5 } from "node:os";
17890
+ import { homedir as homedir6 } from "node:os";
17935
17891
  import { join as join6 } from "node:path";
17936
- function defaultLogRoot(stateDir, home = homedir5()) {
17892
+ function defaultLogRoot(stateDir, home = homedir6()) {
17937
17893
  return stateDir ? join6(stateDir, "logs") : join6(frizzPaths({ home }).state, "logs");
17938
17894
  }
17939
17895
  function runStamp(at) {
17940
17896
  const pad = (value) => String(value).padStart(2, "0");
17941
17897
  return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}-${pad(at.getMinutes())}-${pad(at.getSeconds())}`;
17942
17898
  }
17943
- function runLogPath(stateDir, at = /* @__PURE__ */ new Date(), pid = process.pid, home = homedir5(), env = process.env) {
17899
+ function runLogPath(stateDir, at = /* @__PURE__ */ new Date(), pid = process.pid, home = homedir6(), env = process.env) {
17944
17900
  const name = `frizz-${runStamp(at)}-${pid}.log`;
17945
17901
  const override = env[LOG_PATH_ENV]?.trim();
17946
17902
  if (override) return override.endsWith(".log") ? override : join6(override, name);
@@ -17952,7 +17908,7 @@ function latestLogPath(dir) {
17952
17908
  function linkLatest(dir, target2) {
17953
17909
  const link = latestLogPath(dir);
17954
17910
  try {
17955
- rmSync(link, { force: true });
17911
+ rmSync2(link, { force: true });
17956
17912
  symlinkSync(target2, link);
17957
17913
  } catch {
17958
17914
  try {
@@ -17980,7 +17936,7 @@ function pruneRunLogs(dir, keep = RETAINED_RUNS, days = RETAINED_DAYS, now = Dat
17980
17936
  const stale = dated.filter((entry, index) => index >= keep || entry.at < cutoff);
17981
17937
  for (const entry of stale) {
17982
17938
  try {
17983
- rmSync(join6(dir, entry.name), { force: true });
17939
+ rmSync2(join6(dir, entry.name), { force: true });
17984
17940
  } catch {
17985
17941
  }
17986
17942
  }
@@ -18252,11 +18208,11 @@ import {
18252
18208
  readFileSync as readFileSync6,
18253
18209
  realpathSync as realpathSync2,
18254
18210
  renameSync,
18255
- rmSync as rmSync2,
18211
+ rmSync as rmSync3,
18256
18212
  statSync as statSync3,
18257
18213
  writeFileSync as writeFileSync5
18258
18214
  } from "node:fs";
18259
- import { homedir as homedir6 } from "node:os";
18215
+ import { homedir as homedir7 } from "node:os";
18260
18216
  import { dirname as dirname5, join as join8, resolve } from "node:path";
18261
18217
  import { setTimeout as delay } from "node:timers/promises";
18262
18218
  function errorCode2(error) {
@@ -18272,7 +18228,7 @@ function canonicalHome(home) {
18272
18228
  return resolve(home);
18273
18229
  }
18274
18230
  }
18275
- function globalLaunchLockPath(home = homedir6()) {
18231
+ function globalLaunchLockPath(home = homedir7()) {
18276
18232
  return join8(frizzPaths({ home: canonicalHome(home) }).state, GLOBAL_LOCK_NAME);
18277
18233
  }
18278
18234
  function namedLaunchLockPath(home, name) {
@@ -18356,7 +18312,7 @@ function quarantineLock(lockPath) {
18356
18312
  return false;
18357
18313
  }
18358
18314
  try {
18359
- rmSync2(quarantine2, { recursive: true, force: true });
18315
+ rmSync3(quarantine2, { recursive: true, force: true });
18360
18316
  } catch {
18361
18317
  }
18362
18318
  return true;
@@ -18402,7 +18358,7 @@ function tryAcquireLock(lockPath, adapter) {
18402
18358
  const quarantine2 = `${lockPath}.release-${process.pid}-${owner.token}`;
18403
18359
  try {
18404
18360
  renameSync(lockPath, quarantine2);
18405
- rmSync2(quarantine2, { recursive: true, force: true });
18361
+ rmSync3(quarantine2, { recursive: true, force: true });
18406
18362
  } catch {
18407
18363
  }
18408
18364
  };
@@ -18410,10 +18366,10 @@ function tryAcquireLock(lockPath, adapter) {
18410
18366
  function tryAcquireGlobalLaunchLock(home, adapter) {
18411
18367
  return tryAcquireLock(globalLaunchLockPath(home), adapter);
18412
18368
  }
18413
- function portReservationPath(port, home = homedir6()) {
18369
+ function portReservationPath(port, home = homedir7()) {
18414
18370
  return join8(frizzPaths({ home: canonicalHome(home) }).state, PORT_LOCK_DIR, `${port}.lock`);
18415
18371
  }
18416
- function tryReservePort(port, home = homedir6(), adapter = defaultProcessPlatformAdapter) {
18372
+ function tryReservePort(port, home = homedir7(), adapter = defaultProcessPlatformAdapter) {
18417
18373
  const lockPath = portReservationPath(port, home);
18418
18374
  const release = tryAcquireLock(lockPath, adapter);
18419
18375
  if (release) return release;
@@ -18426,7 +18382,7 @@ function lockTimeoutError(observation) {
18426
18382
  observation.ownerPid === void 0 ? "another Frizz launch still owns the global launch lock" : `another Frizz launch (pid ${observation.ownerPid}) still owns the global launch lock`
18427
18383
  );
18428
18384
  }
18429
- async function acquireGlobalLaunchLock(home = homedir6(), timeoutMs = 1e4, adapter = defaultProcessPlatformAdapter) {
18385
+ async function acquireGlobalLaunchLock(home = homedir7(), timeoutMs = 1e4, adapter = defaultProcessPlatformAdapter) {
18430
18386
  const deadline = Date.now() + Math.max(0, timeoutMs);
18431
18387
  let observation = { stale: false };
18432
18388
  for (; ; ) {
@@ -18594,7 +18550,7 @@ function resolvedIdentity(worktree) {
18594
18550
  }
18595
18551
  return { ...worktree, id: worktreeId };
18596
18552
  }
18597
- function resolveGitProjectIdentity(dir, home = homedir6()) {
18553
+ function resolveGitProjectIdentity(dir, home = homedir7()) {
18598
18554
  let worktree = resolveGitWorktree(dir);
18599
18555
  const existing = resolvedIdentity(worktree);
18600
18556
  if (existing) return existing;
@@ -18668,7 +18624,7 @@ import {
18668
18624
  openSync as openSync3,
18669
18625
  readFileSync as readFileSync7,
18670
18626
  renameSync as renameSync2,
18671
- rmSync as rmSync3,
18627
+ rmSync as rmSync4,
18672
18628
  statSync as statSync4,
18673
18629
  writeFileSync as writeFileSync6
18674
18630
  } from "node:fs";
@@ -18715,7 +18671,7 @@ function atomicJson(path, value) {
18715
18671
  }
18716
18672
  }
18717
18673
  try {
18718
- rmSync3(temp, { force: true });
18674
+ rmSync4(temp, { force: true });
18719
18675
  } catch {
18720
18676
  }
18721
18677
  throw error;
@@ -18729,7 +18685,7 @@ function quarantine(path, suffix) {
18729
18685
  return false;
18730
18686
  }
18731
18687
  try {
18732
- rmSync3(moved, { recursive: true, force: true });
18688
+ rmSync4(moved, { recursive: true, force: true });
18733
18689
  } catch {
18734
18690
  }
18735
18691
  return true;
@@ -19116,7 +19072,7 @@ var init_project_launch = __esm({
19116
19072
  });
19117
19073
 
19118
19074
  // packages/server/src/boot-progress.ts
19119
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync4, writeFileSync as writeFileSync7 } from "node:fs";
19075
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
19120
19076
  import { basename as basename3, dirname as dirname7, join as join10 } from "node:path";
19121
19077
  function bootProgressPath(stateDir) {
19122
19078
  return join10(stateDir, BOOT_PROGRESS_NAME);
@@ -19140,8 +19096,8 @@ var init_boot_progress = __esm({
19140
19096
 
19141
19097
  // packages/server/src/project-root.ts
19142
19098
  import { createHash as createHash3, randomUUID as randomUUID4 } from "node:crypto";
19143
- import { closeSync as closeSync3, existsSync as existsSync3, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync9, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "node:fs";
19144
- import { homedir as homedir7 } from "node:os";
19099
+ import { closeSync as closeSync3, existsSync as existsSync3, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync9, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
19100
+ import { homedir as homedir8 } from "node:os";
19145
19101
  import { dirname as dirname8, join as join11, parse, resolve as resolve2 } from "node:path";
19146
19102
  function projectIdPath(root) {
19147
19103
  return join11(root, FRIZZ_DIR, ID_FILE);
@@ -19188,7 +19144,7 @@ function writeProjectIdFile(root, id) {
19188
19144
  }
19189
19145
  }
19190
19146
  try {
19191
- rmSync5(temp, { force: true });
19147
+ rmSync6(temp, { force: true });
19192
19148
  } catch {
19193
19149
  }
19194
19150
  throw error;
@@ -19198,7 +19154,7 @@ function writeProjectIdFile(root, id) {
19198
19154
  function projectRootLockName(root) {
19199
19155
  return `identity-path-${createHash3("sha256").update(root).digest("hex")}.lock`;
19200
19156
  }
19201
- function ensureProjectIdFile(root, home = homedir7(), seed) {
19157
+ function ensureProjectIdFile(root, home = homedir8(), seed) {
19202
19158
  const existing = readProjectIdFile(root);
19203
19159
  if (existing) return existing;
19204
19160
  const release = acquireNamedLaunchLockSync(home, projectRootLockName(root));
@@ -19220,7 +19176,7 @@ function isNotAGitWorktree(error) {
19220
19176
  function hasAny(dir, names) {
19221
19177
  return names.some((name) => existsSync3(join11(dir, name)));
19222
19178
  }
19223
- function isHomeDirectory(dir, home = homedir7()) {
19179
+ function isHomeDirectory(dir, home = homedir8()) {
19224
19180
  const canonical = (value) => {
19225
19181
  try {
19226
19182
  return realpathSync3(resolve2(value));
@@ -19249,7 +19205,7 @@ function isExistingProjectRoot(dir) {
19249
19205
  function hasProjectMarker(dir) {
19250
19206
  return hasAny(dir, REPO_MARKERS) || hasAny(dir, PROJECT_MARKERS);
19251
19207
  }
19252
- function discoverProjectRoot(cwd = process.cwd(), home = homedir7()) {
19208
+ function discoverProjectRoot(cwd = process.cwd(), home = homedir8()) {
19253
19209
  let dir;
19254
19210
  try {
19255
19211
  dir = resolve2(cwd);
@@ -19293,13 +19249,13 @@ var init_project_root = __esm({
19293
19249
  });
19294
19250
 
19295
19251
  // packages/server/src/project-registry.ts
19296
- import { closeSync as closeSync4, existsSync as existsSync4, fsyncSync as fsyncSync4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync10, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync6, writeFileSync as writeFileSync9 } from "node:fs";
19297
- import { homedir as homedir8 } from "node:os";
19252
+ import { closeSync as closeSync4, existsSync as existsSync4, fsyncSync as fsyncSync4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync10, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
19253
+ import { homedir as homedir9 } from "node:os";
19298
19254
  import { basename as basename4, dirname as dirname9, join as join12 } from "node:path";
19299
- function registryPath(home = homedir8()) {
19255
+ function registryPath(home = homedir9()) {
19300
19256
  return join12(frizzPaths({ home }).data, "registry.json");
19301
19257
  }
19302
- function readRegistry(home = homedir8()) {
19258
+ function readRegistry(home = homedir9()) {
19303
19259
  let raw;
19304
19260
  try {
19305
19261
  raw = readFileSync10(registryPath(home), "utf8");
@@ -19319,7 +19275,7 @@ function readRegistry(home = homedir8()) {
19319
19275
  return { ...EMPTY, projects: [] };
19320
19276
  }
19321
19277
  }
19322
- function writeRegistry(registry, home = homedir8()) {
19278
+ function writeRegistry(registry, home = homedir9()) {
19323
19279
  const path = registryPath(home);
19324
19280
  mkdirSync9(dirname9(path), { recursive: true });
19325
19281
  const temp = `${path}.${process.pid}.tmp`;
@@ -19340,7 +19296,7 @@ function writeRegistry(registry, home = homedir8()) {
19340
19296
  }
19341
19297
  }
19342
19298
  try {
19343
- rmSync6(temp, { force: true });
19299
+ rmSync7(temp, { force: true });
19344
19300
  } catch {
19345
19301
  }
19346
19302
  throw error;
@@ -19375,7 +19331,7 @@ function canonicalPath(dir) {
19375
19331
  return dir;
19376
19332
  }
19377
19333
  }
19378
- function registerProject(input, home = homedir8()) {
19334
+ function registerProject(input, home = homedir9()) {
19379
19335
  input = { ...input, dir: canonicalPath(input.dir) };
19380
19336
  const registry = readRegistry(home);
19381
19337
  const at = (input.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
@@ -19410,7 +19366,7 @@ function registerProject(input, home = homedir8()) {
19410
19366
  writeRegistry(registry, home);
19411
19367
  return { entry, action: "created" };
19412
19368
  }
19413
- function listProjects(home = homedir8()) {
19369
+ function listProjects(home = homedir9()) {
19414
19370
  const byRecency = (a, b) => a.lastOpenedAt < b.lastOpenedAt ? 1 : a.lastOpenedAt > b.lastOpenedAt ? -1 : 0;
19415
19371
  return readRegistry(home).projects.map((p) => ({ ...p, stale: !existsSync4(p.path) })).sort((a, b) => {
19416
19372
  if (a.order === void 0 && b.order === void 0) return byRecency(a, b);
@@ -19419,7 +19375,7 @@ function listProjects(home = homedir8()) {
19419
19375
  return a.order - b.order;
19420
19376
  });
19421
19377
  }
19422
- function findByPath(path, home = homedir8()) {
19378
+ function findByPath(path, home = homedir9()) {
19423
19379
  const canonical = canonicalPath(path);
19424
19380
  return readRegistry(home).projects.find((p) => p.path === canonical || p.path === path);
19425
19381
  }
@@ -19475,7 +19431,7 @@ var init_project_registry = __esm({
19475
19431
  // src/production.ts
19476
19432
  init_local_origin();
19477
19433
  import { readFileSync as readFileSync13 } from "node:fs";
19478
- import { homedir as homedir10 } from "node:os";
19434
+ import { homedir as homedir11 } from "node:os";
19479
19435
  import { fileURLToPath as fileURLToPath2 } from "node:url";
19480
19436
  import { join as join15 } from "node:path";
19481
19437
 
@@ -19723,7 +19679,7 @@ function openCdpSession(browserPath, dataPath) {
19723
19679
  cmdPipe.on("error", failAll);
19724
19680
  resPipe.on("error", failAll);
19725
19681
  child.on("exit", () => failAll(new Error("chrome exited before the CDP handshake completed")));
19726
- const call = (method, params = {}, timeoutMs = 2e4) => new Promise((resolve5, reject) => {
19682
+ const call2 = (method, params = {}, timeoutMs = 2e4) => new Promise((resolve5, reject) => {
19727
19683
  const id = ++seq;
19728
19684
  pending.set(id, { resolve: resolve5, reject });
19729
19685
  cmdPipe.write(JSON.stringify({ id, method, params }) + "\0");
@@ -19735,7 +19691,7 @@ function openCdpSession(browserPath, dataPath) {
19735
19691
  }, timeoutMs).unref();
19736
19692
  });
19737
19693
  const close = async () => {
19738
- await call("Browser.close").catch(() => {
19694
+ await call2("Browser.close").catch(() => {
19739
19695
  });
19740
19696
  if (child.exitCode !== null || child.signalCode !== null) return;
19741
19697
  await new Promise((resolve5) => {
@@ -19750,7 +19706,7 @@ function openCdpSession(browserPath, dataPath) {
19750
19706
  });
19751
19707
  });
19752
19708
  };
19753
- return { call, close, kill: () => child.kill() };
19709
+ return { call: call2, close, kill: () => child.kill() };
19754
19710
  }
19755
19711
  async function cdpInstallPwa(browserPath, url, dataPath) {
19756
19712
  const manifestId = manifestIdFor(url);
@@ -19935,8 +19891,41 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19935
19891
  import { dirname, join as join2 } from "node:path";
19936
19892
  var CODE_BYTES = 16;
19937
19893
  var SESSION_BYTES = 32;
19894
+ var SESSION_ID_BYTES = 6;
19938
19895
  var DEFAULT_CODE_TTL_MS = 5 * 6e4;
19939
19896
  var DEFAULT_SESSION_TTL_MS = 365 * 24 * 60 * 6e4;
19897
+ function memorySessionDirectory(seed = []) {
19898
+ const records = new Map(seed.map((r) => [r.id, r]));
19899
+ return {
19900
+ record: (r) => void records.set(r.id, r),
19901
+ isRevoked: (id) => records.get(id)?.revokedAt !== void 0,
19902
+ // Reverse FIRST, then sort. Array.sort is stable, so two devices that redeemed in the same
19903
+ // millisecond — which two taps on one page reliably are — keep newest-inserted-first instead of
19904
+ // falling back to insertion order and reading as oldest first.
19905
+ list: () => [...records.values()].reverse().sort((a, b) => b.createdAt - a.createdAt),
19906
+ revoke(id) {
19907
+ const found = records.get(id);
19908
+ if (!found || found.revokedAt !== void 0) return false;
19909
+ found.revokedAt = Date.now();
19910
+ return true;
19911
+ },
19912
+ revokeAll() {
19913
+ let n = 0;
19914
+ for (const r of records.values()) if (r.revokedAt === void 0) {
19915
+ r.revokedAt = Date.now();
19916
+ n++;
19917
+ }
19918
+ return n;
19919
+ }
19920
+ };
19921
+ }
19922
+ function describeDevice(userAgent) {
19923
+ const ua = userAgent ?? "";
19924
+ if (!ua.trim()) return "unknown device";
19925
+ const os = /iPhone/i.test(ua) ? "iPhone" : /iPad/i.test(ua) ? "iPad" : /Android/i.test(ua) ? "Android" : /Mac OS X|Macintosh/i.test(ua) ? "macOS" : /Windows/i.test(ua) ? "Windows" : /Linux/i.test(ua) ? "Linux" : "unknown device";
19926
+ const browser = /Edg\//i.test(ua) ? "Edge" : /OPR\/|Opera/i.test(ua) ? "Opera" : /Firefox\//i.test(ua) ? "Firefox" : /Chrome\//i.test(ua) ? "Chrome" : /Safari\//i.test(ua) ? "Safari" : "";
19927
+ return browser ? `${browser} on ${os}` : os;
19928
+ }
19940
19929
  function defaultRandomToken(bytes) {
19941
19930
  return randomBytes(bytes).toString("base64url");
19942
19931
  }
@@ -19945,13 +19934,15 @@ function secretsMatch(a, b) {
19945
19934
  const right = Buffer.from(b);
19946
19935
  return left.length === right.length && timingSafeEqual(left, right);
19947
19936
  }
19948
- var AccessStore = class {
19937
+ var AccessStore = class _AccessStore {
19949
19938
  codes = /* @__PURE__ */ new Map();
19950
19939
  options;
19940
+ sessions;
19951
19941
  /** Signing key for sessions. Persisted by the caller; a fresh one signs every device out. */
19952
19942
  signingKey;
19953
19943
  constructor(options2 = {}) {
19954
19944
  this.signingKey = options2.signingKey ?? randomBytes(32);
19945
+ this.sessions = options2.sessions ?? memorySessionDirectory();
19955
19946
  this.options = {
19956
19947
  codeTtlMs: options2.codeTtlMs ?? DEFAULT_CODE_TTL_MS,
19957
19948
  sessionTtlMs: options2.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,
@@ -19980,7 +19971,7 @@ var AccessStore = class {
19980
19971
  * atomicity is what makes "single-use" true rather than aspirational — it is the first thing the
19981
19972
  * tests attack.
19982
19973
  */
19983
- redeem(code) {
19974
+ redeem(code, device) {
19984
19975
  if (!code) return { ok: false, reason: "unknown" };
19985
19976
  const stored = this.codes.get(code);
19986
19977
  if (!stored || !secretsMatch(stored.code, code)) return { ok: false, reason: "unknown" };
@@ -19993,18 +19984,32 @@ var AccessStore = class {
19993
19984
  stored.consumedAt = now;
19994
19985
  this.sweep();
19995
19986
  this.options.onConsumed?.(code);
19996
- return { ok: true, session: this.mintSession(now), expiresAt: now + this.options.sessionTtlMs };
19987
+ const minted = this.mintSession(now);
19988
+ this.sessions.record({ id: minted.id, label: device ?? "unknown device", createdAt: now });
19989
+ return { ok: true, session: minted.session, expiresAt: now + this.options.sessionTtlMs, id: minted.id };
19997
19990
  }
19998
19991
  /**
19999
- * A session is `<expiry>.<hmac>` — stateless, so it needs no server-side table, and signed with a key
20000
- * the caller persists so it survives a restart. Self-describing expiry means a stale cookie is
20001
- * rejected without a lookup, and rotating the key revokes every outstanding session at once.
19992
+ * A session is `<expiry>.<id>.<nonce>.<hmac>` — signed with a key the caller persists so it survives
19993
+ * a restart, and self-describing enough that a stale cookie is rejected without any lookup.
19994
+ *
19995
+ * The `id` is what makes ONE device signable-out. It is short because an operator types it into
19996
+ * `frizz --sign-out <id>`, and it is inside the signed payload so it cannot be swapped for another
19997
+ * device's. Rotating the key still revokes everything at once; this is the scalpel beside that axe.
19998
+ *
19999
+ * A session minted before ids existed has a two-part payload and still verifies — it simply has no id
20000
+ * to revoke individually. Upgrading must not sign every device out.
20002
20001
  */
20003
20002
  mintSession(now) {
20004
20003
  const expiresAt = now + this.options.sessionTtlMs;
20004
+ const id = this.options.randomToken(SESSION_ID_BYTES);
20005
20005
  const nonce = this.options.randomToken(SESSION_BYTES);
20006
- const payload = `${expiresAt}.${nonce}`;
20007
- return `${payload}.${this.sign(payload)}`;
20006
+ const payload = `${expiresAt}.${id}.${nonce}`;
20007
+ return { session: `${payload}.${this.sign(payload)}`, id };
20008
+ }
20009
+ /** The id inside a session payload, or null for a legacy two-part one. */
20010
+ static idOf(payload) {
20011
+ const parts = payload.split(".");
20012
+ return parts.length >= 3 ? parts[1] ?? null : null;
20008
20013
  }
20009
20014
  sign(payload) {
20010
20015
  return createHmac("sha256", this.signingKey).update(payload).digest("base64url");
@@ -20018,7 +20023,9 @@ var AccessStore = class {
20018
20023
  const signature = session.slice(cut + 1);
20019
20024
  if (!secretsMatch(signature, this.sign(payload))) return false;
20020
20025
  const expiresAt = Number(payload.slice(0, payload.indexOf(".")));
20021
- return Number.isFinite(expiresAt) && this.options.now() < expiresAt;
20026
+ if (!Number.isFinite(expiresAt) || this.options.now() >= expiresAt) return false;
20027
+ const id = _AccessStore.idOf(payload);
20028
+ return !(id && this.sessions.isRevoked(id));
20022
20029
  }
20023
20030
  /**
20024
20031
  * Drop codes past their expiry so a long-lived board does not accumulate them forever.
@@ -20040,6 +20047,46 @@ var AccessStore = class {
20040
20047
  return [...this.codes.values()].filter((stored) => stored.consumedAt === void 0).map(({ code, createdAt, expiresAt }) => ({ code, createdAt, expiresAt }));
20041
20048
  }
20042
20049
  };
20050
+ function fileSessionDirectory(stateDir, now = Date.now) {
20051
+ const path = join2(stateDir, "sessions.json");
20052
+ let records = [];
20053
+ try {
20054
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
20055
+ if (Array.isArray(parsed)) {
20056
+ records = parsed.filter(
20057
+ (r) => !!r && typeof r.id === "string" && typeof r.createdAt === "number"
20058
+ );
20059
+ }
20060
+ } catch {
20061
+ }
20062
+ const inner = memorySessionDirectory(records);
20063
+ const flush = () => {
20064
+ try {
20065
+ mkdirSync(dirname(path), { recursive: true });
20066
+ writeFileSync(path, JSON.stringify(inner.list()), { mode: 384 });
20067
+ } catch (error) {
20068
+ if (process.env.FRIZZ_DEBUG_SESSIONS) console.error("[sessions] flush failed:", error);
20069
+ }
20070
+ };
20071
+ return {
20072
+ record(r) {
20073
+ inner.record(r);
20074
+ flush();
20075
+ },
20076
+ isRevoked: (id) => inner.isRevoked(id),
20077
+ list: () => inner.list(),
20078
+ revoke(id) {
20079
+ const ok = inner.revoke(id);
20080
+ if (ok) flush();
20081
+ return ok;
20082
+ },
20083
+ revokeAll() {
20084
+ const n = inner.revokeAll();
20085
+ if (n > 0) flush();
20086
+ return n;
20087
+ }
20088
+ };
20089
+ }
20043
20090
  function loadOrCreateSessionKey(stateDir) {
20044
20091
  const path = join2(stateDir, "session-key");
20045
20092
  try {
@@ -21746,6 +21793,7 @@ var SUPERVISOR_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/restart`;
21746
21793
  var SUPERVISOR_UPDATE_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/update-restart`;
21747
21794
  var SUPERVISOR_STATUS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/status`;
21748
21795
  var SUPERVISOR_ACCESS_CODE_PATH = `${SUPERVISOR_CONTROL_PREFIX}/access-code`;
21796
+ var SUPERVISOR_SESSIONS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/sessions`;
21749
21797
  var SUPERVISOR_CONTROL_PROTOCOL = 1;
21750
21798
  function responseJson(res, status, value) {
21751
21799
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
@@ -21786,7 +21834,7 @@ function unlistedHostPage(name) {
21786
21834
  }
21787
21835
  function isControlRequest(req) {
21788
21836
  const url = new URL(req.url ?? "/", "http://frizz.invalid");
21789
- return url.pathname === SUPERVISOR_RESTART_PATH || url.pathname === SUPERVISOR_UPDATE_RESTART_PATH || url.pathname === SUPERVISOR_STATUS_PATH || url.pathname === SUPERVISOR_ACCESS_CODE_PATH;
21837
+ 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;
21790
21838
  }
21791
21839
  function isLocalImageRequest(req) {
21792
21840
  if (req.method !== "GET" && req.method !== "HEAD") return false;
@@ -21794,6 +21842,22 @@ function isLocalImageRequest(req) {
21794
21842
  return LOCAL_IMAGE_PATH.test(pathname);
21795
21843
  }
21796
21844
  var LOCAL_IMAGE_PATH = new RegExp(`^${FRIZZ_ROUTE_PREFIX}(?:/[^/]+)?/local-image$`);
21845
+ async function readJsonBody(req, limit = 64 * 1024) {
21846
+ const chunks = [];
21847
+ let size = 0;
21848
+ for await (const chunk of req) {
21849
+ size += chunk.byteLength;
21850
+ if (size > limit) return null;
21851
+ chunks.push(chunk);
21852
+ }
21853
+ if (chunks.length === 0) return {};
21854
+ try {
21855
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
21856
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
21857
+ } catch {
21858
+ return null;
21859
+ }
21860
+ }
21797
21861
  var RestartSupervisorProxy = class {
21798
21862
  server = null;
21799
21863
  restartInFlight = null;
@@ -21808,6 +21872,8 @@ var RestartSupervisorProxy = class {
21808
21872
  * judged here first.
21809
21873
  */
21810
21874
  policy;
21875
+ /** The origin a tunnel or proxy serves this board at; undefined while loopback-only. Live: see setPublicOrigin. */
21876
+ publicOrigin;
21811
21877
  /**
21812
21878
  * Codes and sessions for the public origin, or null when no public origin is declared (loopback-only
21813
21879
  * boards are never gated, so there is nothing to store).
@@ -21818,14 +21884,28 @@ var RestartSupervisorProxy = class {
21818
21884
  constructor(options2) {
21819
21885
  this.options = options2;
21820
21886
  this.host = options2.host ?? LOOPBACK_BIND_HOST;
21821
- this.access = options2.publicOrigin ? new AccessStore({
21887
+ this.publicOrigin = options2.publicOrigin;
21888
+ this.access = new AccessStore({
21822
21889
  onConsumed: () => options2.onCodeConsumed?.(),
21823
- ...options2.sessionKey ? { signingKey: options2.sessionKey } : {}
21824
- }) : null;
21825
- this.policy = {
21890
+ ...options2.sessionKey ? { signingKey: options2.sessionKey } : {},
21891
+ ...options2.sessionDirectory ? { sessions: options2.sessionDirectory } : {}
21892
+ });
21893
+ this.policy = this.buildPolicy();
21894
+ }
21895
+ /**
21896
+ * Change the public origin on a running board — what the R pane does when the operator picks or
21897
+ * clears a remote setup. Requests already in flight finish under the policy they started with; the
21898
+ * next one is judged by the new origin. Sessions survive the change.
21899
+ */
21900
+ setPublicOrigin(origin) {
21901
+ this.publicOrigin = origin;
21902
+ this.policy = this.buildPolicy();
21903
+ }
21904
+ buildPolicy() {
21905
+ return {
21826
21906
  exposed: bindHostIsExposed(this.host),
21827
- allowedHosts: options2.allowedHosts ?? [],
21828
- ...options2.publicOrigin ? { publicOrigin: options2.publicOrigin } : {}
21907
+ allowedHosts: this.options.allowedHosts ?? [],
21908
+ ...this.publicOrigin ? { publicOrigin: this.publicOrigin } : {}
21829
21909
  };
21830
21910
  }
21831
21911
  /**
@@ -21835,11 +21915,11 @@ var RestartSupervisorProxy = class {
21835
21915
  * asking at once, must not silently break the first QR somebody is still walking towards.
21836
21916
  */
21837
21917
  issueAccessCode() {
21838
- return this.access?.issue() ?? null;
21918
+ return this.publicOrigin ? this.access.issue() : null;
21839
21919
  }
21840
21920
  /** The full URL to show as a link or a QR. */
21841
21921
  accessUrl(code) {
21842
- const origin = this.options.publicOrigin;
21922
+ const origin = this.publicOrigin;
21843
21923
  return origin ? `${origin}/?${ACCESS_CODE_PARAM}=${encodeURIComponent(code)}` : null;
21844
21924
  }
21845
21925
  async listen() {
@@ -21980,6 +22060,42 @@ var RestartSupervisorProxy = class {
21980
22060
  responseJson(res, 200, { url, expiresAt: link.expiresAt });
21981
22061
  return;
21982
22062
  }
22063
+ if (pathname === SUPERVISOR_SESSIONS_PATH) {
22064
+ if (this.arrivedPublicly(req)) {
22065
+ res.writeHead(403);
22066
+ res.end("Forbidden");
22067
+ return;
22068
+ }
22069
+ if (!this.publicOrigin) {
22070
+ responseJson(res, 409, { error: "this board has no public origin, so nothing is signed in" });
22071
+ return;
22072
+ }
22073
+ if (req.method === "GET") {
22074
+ responseJson(res, 200, { sessions: this.access.sessions.list() });
22075
+ return;
22076
+ }
22077
+ if (req.method !== "POST") {
22078
+ res.writeHead(405, { allow: "GET, POST" });
22079
+ res.end();
22080
+ return;
22081
+ }
22082
+ const body = await readJsonBody(req);
22083
+ const id = typeof body?.id === "string" ? body.id : void 0;
22084
+ if (body?.all === true) {
22085
+ responseJson(res, 200, { signedOut: this.access.sessions.revokeAll() });
22086
+ return;
22087
+ }
22088
+ if (!id) {
22089
+ responseJson(res, 400, { error: "name a device id, or pass all: true" });
22090
+ return;
22091
+ }
22092
+ if (!this.access.sessions.revoke(id)) {
22093
+ responseJson(res, 404, { error: `no signed-in device has the id ${id}` });
22094
+ return;
22095
+ }
22096
+ responseJson(res, 200, { signedOut: 1 });
22097
+ return;
22098
+ }
21983
22099
  if (pathname === SUPERVISOR_STATUS_PATH && req.method === "GET") {
21984
22100
  responseJson(res, 200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, ...this.status() });
21985
22101
  return;
@@ -22036,7 +22152,7 @@ var RestartSupervisorProxy = class {
22036
22152
  * a tunnelled board stays bound to 127.0.0.1 while being reachable from the internet by name.
22037
22153
  */
22038
22154
  arrivedPublicly(req) {
22039
- const declared = this.options.publicOrigin;
22155
+ const declared = this.publicOrigin;
22040
22156
  if (!declared) return false;
22041
22157
  const host = parseLocalHost(req.headers.host, this.options.port, this.policy);
22042
22158
  return !!host && !LOOPBACK_HOSTNAMES.has(host.hostname);
@@ -22056,7 +22172,7 @@ var RestartSupervisorProxy = class {
22056
22172
  * staying in somebody's URL bar.
22057
22173
  */
22058
22174
  sessionAccepted(req) {
22059
- if (!this.access) return true;
22175
+ if (!this.publicOrigin) return true;
22060
22176
  return this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
22061
22177
  }
22062
22178
  /**
@@ -22067,7 +22183,7 @@ var RestartSupervisorProxy = class {
22067
22183
  const url = new URL(req.url ?? "/", "http://frizz.invalid");
22068
22184
  const code = url.searchParams.get(ACCESS_CODE_PARAM);
22069
22185
  if (code === null) return false;
22070
- const redeemed = this.access?.redeem(code);
22186
+ const redeemed = this.access.redeem(code, describeDevice(req.headers["user-agent"]));
22071
22187
  if (!redeemed?.ok) {
22072
22188
  res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
22073
22189
  res.end(unauthorizedPage(redeemed?.reason));
@@ -22171,66 +22287,78 @@ var RestartSupervisorProxy = class {
22171
22287
  }
22172
22288
  };
22173
22289
 
22174
- // src/access-pane.ts
22175
- var ALT_SCREEN_ON = "\x1B[?1049h";
22176
- var ALT_SCREEN_OFF = "\x1B[?1049l";
22177
- var HIDE_CURSOR = "\x1B[?25l";
22178
- var SHOW_CURSOR = "\x1B[?25h";
22179
- var CLEAR = "\x1B[2J\x1B[H";
22180
- var DIM = "\x1B[2m";
22181
- var RESET2 = "\x1B[0m";
22182
- var OPEN_KEYS = /* @__PURE__ */ new Set(["l", "L"]);
22183
- var CTRL_C = "";
22184
- function secondsUntil(expiresAt, now) {
22185
- return Math.max(0, Math.ceil((expiresAt - now) / 1e3));
22290
+ // src/sessions-cli.ts
22291
+ var ago = (at, now = Date.now()) => {
22292
+ const seconds = Math.max(0, Math.round((now - at) / 1e3));
22293
+ if (seconds < 90) return "just now";
22294
+ const minutes = Math.round(seconds / 60);
22295
+ if (minutes < 90) return `${minutes}m ago`;
22296
+ const hours = Math.round(minutes / 60);
22297
+ if (hours < 36) return `${hours}h ago`;
22298
+ return `${Math.round(hours / 24)}d ago`;
22299
+ };
22300
+ async function call(port, init) {
22301
+ const response = await fetch(`http://127.0.0.1:${port}${SUPERVISOR_SESSIONS_PATH}`, {
22302
+ ...init,
22303
+ headers: { origin: `http://127.0.0.1:${port}`, ...init?.body ? { "content-type": "application/json" } : {}, ...init?.headers }
22304
+ }).catch(() => void 0);
22305
+ if (!response) {
22306
+ console.error("frizz: the running board did not answer");
22307
+ process.exit(1);
22308
+ }
22309
+ const body = await response.json().catch(() => void 0);
22310
+ if (!response.ok) {
22311
+ console.error(`frizz: ${body?.error ?? `the board answered ${response.status}`}`);
22312
+ process.exit(1);
22313
+ }
22314
+ return body;
22315
+ }
22316
+ async function listSessions(port) {
22317
+ const body = await call(port);
22318
+ const sessions = body?.sessions ?? [];
22319
+ const live = sessions.filter((s) => s.revokedAt === void 0);
22320
+ if (live.length === 0) {
22321
+ console.log("No device has redeemed an access link.");
22322
+ process.exit(0);
22323
+ }
22324
+ const width = Math.max(...live.map((s) => s.id.length));
22325
+ for (const session of live) {
22326
+ console.log(` ${session.id.padEnd(width)} ${session.label} ${ago(session.createdAt)}`);
22327
+ }
22328
+ console.log(`
22329
+ frizz --sign-out <id> sign one out
22330
+ frizz --sign-out all sign every device out`);
22331
+ process.exit(0);
22332
+ }
22333
+ async function signOutSession(port, target2) {
22334
+ const all = target2 === "all";
22335
+ const body = await call(port, {
22336
+ method: "POST",
22337
+ body: JSON.stringify(all ? { all: true } : { id: target2 })
22338
+ });
22339
+ const count = Number(body?.signedOut ?? 0);
22340
+ if (all) {
22341
+ console.log(count === 0 ? "No device was signed in." : `Signed out ${count} device${count === 1 ? "" : "s"}.`);
22342
+ } else {
22343
+ console.log(`Signed out ${target2}.`);
22344
+ }
22345
+ process.exit(0);
22186
22346
  }
22187
- function installAccessPane(options2) {
22347
+
22348
+ // src/pane-host.ts
22349
+ var CTRL_C = "";
22350
+ function installPaneHost(options2) {
22188
22351
  const input = options2.input ?? process.stdin;
22189
22352
  const output = options2.output ?? process.stdout;
22190
- const now = options2.now ?? Date.now;
22191
22353
  const onInterrupt = options2.onInterrupt ?? (() => process.kill(process.pid, "SIGINT"));
22192
22354
  if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return null;
22193
- let open = false;
22355
+ let active = null;
22194
22356
  let disposed = false;
22195
- let shown = null;
22196
- let consumed = false;
22197
- const paint = () => {
22198
- if (!shown) return;
22199
- const remaining = secondsUntil(shown.expiresAt, now());
22200
- const status = consumed ? "This link has been used. Press L for another." : remaining === 0 ? "This link has expired. Press L for another." : `Single use, expires in ${remaining}s.`;
22201
- output.write(CLEAR);
22202
- output.write("\n");
22203
- for (const row of renderQrLines(shown.url)) output.write(` ${row}
22204
- `);
22205
- output.write(`
22206
- ${shown.url}
22207
- `);
22208
- output.write(`
22209
- ${DIM}${status} Press any other key to close.${RESET2}
22210
- `);
22211
- };
22212
- let ticker;
22213
- const close = () => {
22214
- if (!open) return;
22215
- open = false;
22216
- shown = null;
22217
- consumed = false;
22218
- if (ticker) clearInterval(ticker);
22219
- ticker = void 0;
22220
- output.write(ALT_SCREEN_OFF);
22221
- output.write(SHOW_CURSOR);
22222
- };
22223
- const openPane = () => {
22224
- const link = options2.issue();
22225
- if (!link) return;
22226
- shown = link;
22227
- consumed = false;
22228
- open = true;
22229
- output.write(ALT_SCREEN_ON);
22230
- output.write(HIDE_CURSOR);
22231
- paint();
22232
- ticker = setInterval(paint, 1e3);
22233
- ticker.unref?.();
22357
+ const closeActive = () => {
22358
+ if (!active) return;
22359
+ const pane = active;
22360
+ active = null;
22361
+ pane.close();
22234
22362
  };
22235
22363
  const onKey = (chunk) => {
22236
22364
  const key = chunk.toString();
@@ -22239,16 +22367,17 @@ function installAccessPane(options2) {
22239
22367
  onInterrupt();
22240
22368
  return;
22241
22369
  }
22242
- if (open) {
22243
- close();
22370
+ if (active) {
22371
+ if (active.key(key) === "close") closeActive();
22244
22372
  return;
22245
22373
  }
22246
- if (OPEN_KEYS.has(key)) openPane();
22374
+ const pane = options2.bindings[key];
22375
+ if (pane && pane.open()) active = pane;
22247
22376
  };
22248
22377
  const restore = () => {
22249
22378
  if (disposed) return;
22250
22379
  disposed = true;
22251
- close();
22380
+ closeActive();
22252
22381
  input.off("data", onKey);
22253
22382
  try {
22254
22383
  input.setRawMode?.(false);
@@ -22262,9 +22391,70 @@ function installAccessPane(options2) {
22262
22391
  input.setEncoding("utf8");
22263
22392
  input.on("data", onKey);
22264
22393
  process.on("exit", restore);
22394
+ return { dispose: restore, isOpen: () => active !== null };
22395
+ }
22396
+
22397
+ // src/access-pane.ts
22398
+ var ALT_SCREEN_ON = "\x1B[?1049h";
22399
+ var ALT_SCREEN_OFF = "\x1B[?1049l";
22400
+ var HIDE_CURSOR = "\x1B[?25l";
22401
+ var SHOW_CURSOR = "\x1B[?25h";
22402
+ var CLEAR = "\x1B[2J\x1B[H";
22403
+ var DIM = "\x1B[2m";
22404
+ var RESET2 = "\x1B[0m";
22405
+ function secondsUntil(expiresAt, now) {
22406
+ return Math.max(0, Math.ceil((expiresAt - now) / 1e3));
22407
+ }
22408
+ function createAccessPane(options2) {
22409
+ const output = options2.output ?? process.stdout;
22410
+ const now = options2.now ?? Date.now;
22411
+ let open = false;
22412
+ let shown = null;
22413
+ let consumed = false;
22414
+ let ticker;
22415
+ const paint = () => {
22416
+ if (!shown) return;
22417
+ const remaining = secondsUntil(shown.expiresAt, now());
22418
+ const status = consumed ? "This link has been used. Press L for another." : remaining === 0 ? "This link has expired. Press L for another." : `Single use, expires in ${remaining}s.`;
22419
+ output.write(CLEAR);
22420
+ output.write("\n");
22421
+ for (const row of renderQrLines(shown.url)) output.write(` ${row}
22422
+ `);
22423
+ output.write(`
22424
+ ${shown.url}
22425
+ `);
22426
+ output.write(`
22427
+ ${DIM}${status} Press any other key to close.${RESET2}
22428
+ `);
22429
+ };
22265
22430
  return {
22266
- dispose: restore,
22267
- markConsumed: () => {
22431
+ open() {
22432
+ const link = options2.issue();
22433
+ if (!link) return false;
22434
+ shown = link;
22435
+ consumed = false;
22436
+ open = true;
22437
+ output.write(ALT_SCREEN_ON);
22438
+ output.write(HIDE_CURSOR);
22439
+ paint();
22440
+ ticker = setInterval(paint, 1e3);
22441
+ ticker.unref?.();
22442
+ return true;
22443
+ },
22444
+ key() {
22445
+ return "close";
22446
+ },
22447
+ close() {
22448
+ if (!open) return;
22449
+ open = false;
22450
+ shown = null;
22451
+ consumed = false;
22452
+ if (ticker) clearInterval(ticker);
22453
+ ticker = void 0;
22454
+ output.write(ALT_SCREEN_OFF);
22455
+ output.write(SHOW_CURSOR);
22456
+ },
22457
+ markConsumed() {
22268
22458
  if (!open) return;
22269
22459
  consumed = true;
22270
22460
  paint();
@@ -22272,10 +22462,13 @@ function installAccessPane(options2) {
22272
22462
  };
22273
22463
  }
22274
22464
 
22465
+ // src/remote-controller.ts
22466
+ import { homedir as homedir4 } from "node:os";
22467
+
22275
22468
  // src/cloud.ts
22276
22469
  init_src();
22277
22470
  import { spawn as spawn2 } from "node:child_process";
22278
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
22471
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3, rmSync } from "node:fs";
22279
22472
  import { createInterface } from "node:readline/promises";
22280
22473
  import { homedir as homedir3 } from "node:os";
22281
22474
  import { dirname as dirname3, join as join4 } from "node:path";
@@ -22724,6 +22917,14 @@ function connectRelay(options2) {
22724
22917
  }
22725
22918
 
22726
22919
  // src/cloud.ts
22920
+ function isExternalConfig(config) {
22921
+ return config.serve === "external";
22922
+ }
22923
+ function describeCloudConfig(config) {
22924
+ if (isClaimedConfig(config)) return "frizz.sh";
22925
+ if (isExternalConfig(config)) return config.provider === "tailscale" ? "Tailscale" : "your proxy";
22926
+ return "Cloudflare Tunnel";
22927
+ }
22727
22928
  var REGISTRAR_IS_LIVE = true;
22728
22929
  function isClaimedConfig(config) {
22729
22930
  return typeof config.claim === "string" && config.claim.length > 0;
@@ -22731,6 +22932,22 @@ function isClaimedConfig(config) {
22731
22932
  function isRelayConfig(config) {
22732
22933
  return isClaimedConfig(config) && config.serve === "relay";
22733
22934
  }
22935
+ var CLAIM_ZONE = "frizz.sh";
22936
+ function zoneClaimLabel(hostname) {
22937
+ const suffix = `.${CLAIM_ZONE}`;
22938
+ if (!hostname.endsWith(suffix)) return null;
22939
+ const label = hostname.slice(0, -suffix.length);
22940
+ return label.length > 0 && !label.includes(".") ? label : null;
22941
+ }
22942
+ async function reconcileCloudConfig(config, port, home = homedir3(), onNotice, origin, github = githubCli) {
22943
+ if (isRelayConfig(config)) return config;
22944
+ const label = config.claim ?? zoneClaimLabel(config.hostname);
22945
+ if (!label) return config;
22946
+ onNotice?.(
22947
+ 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}`
22948
+ );
22949
+ return establishCloudConfig(label, port, home, origin, github);
22950
+ }
22734
22951
  function cloudConfigPath(home = homedir3()) {
22735
22952
  return join4(home, ".frizz", "cloud.json");
22736
22953
  }
@@ -22740,12 +22957,14 @@ function readCloudConfig(home = homedir3()) {
22740
22957
  if (typeof parsed.hostname !== "string" || !parsed.hostname) return null;
22741
22958
  const claimed = typeof parsed.claim === "string" && parsed.claim.length > 0;
22742
22959
  const named = typeof parsed.tunnel === "string" && parsed.tunnel.length > 0;
22743
- if (!claimed && !named) return null;
22960
+ const external = parsed.serve === "external";
22961
+ if (!claimed && !named && !external) return null;
22744
22962
  return {
22745
22963
  hostname: parsed.hostname,
22746
22964
  ...named ? { tunnel: parsed.tunnel } : {},
22747
22965
  ...claimed ? { claim: parsed.claim } : {},
22748
- ...parsed.serve === "relay" || parsed.serve === "tunnel" ? { serve: parsed.serve } : {},
22966
+ ...parsed.serve === "relay" || parsed.serve === "tunnel" || external ? { serve: parsed.serve } : {},
22967
+ ...external && (parsed.provider === "tailscale" || parsed.provider === "other") ? { provider: parsed.provider } : {},
22749
22968
  ...parsed.config ? { config: parsed.config } : {}
22750
22969
  };
22751
22970
  } catch {
@@ -22769,6 +22988,9 @@ function writeTunnelToken(token, home = homedir3()) {
22769
22988
  writeFileSync3(path, `${token}
22770
22989
  `, { mode: 384 });
22771
22990
  }
22991
+ function deleteCloudConfig(home = homedir3()) {
22992
+ rmSync(cloudConfigPath(home), { force: true });
22993
+ }
22772
22994
  function writeCloudConfig(config, home = homedir3()) {
22773
22995
  const path = cloudConfigPath(home);
22774
22996
  mkdirSync3(dirname3(path), { recursive: true });
@@ -22782,19 +23004,6 @@ function normalizeHostname(raw) {
22782
23004
  }
22783
23005
  return trimmed.toLowerCase();
22784
23006
  }
22785
- async function promptForCloudName() {
22786
- if (!process.stdin.isTTY) {
22787
- throw new Error(
22788
- "--cloud needs a saved name when there is no terminal to ask \u2014 run it once interactively first"
22789
- );
22790
- }
22791
- const rl = createInterface({ input: process.stdin, output: process.stdout });
22792
- try {
22793
- return (await rl.question("Name for this board \u2014 a word claims <name>.frizz.sh, or paste a hostname you already run a tunnel for: ")).trim();
22794
- } finally {
22795
- rl.close();
22796
- }
22797
- }
22798
23007
  async function promptForTunnelName(hostname, fallback) {
22799
23008
  if (!process.stdin.isTTY) return fallback;
22800
23009
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -22808,8 +23017,8 @@ async function promptForTunnelName(hostname, fallback) {
22808
23017
  async function establishCloudConfig(answer, port, home = homedir3(), origin, github = githubCli) {
22809
23018
  if (answer.includes(".")) {
22810
23019
  const hostname = normalizeHostname(answer);
22811
- const tunnel2 = await promptForTunnelName(hostname, hostname.split(".")[0]);
22812
- return { hostname, tunnel: tunnel2 };
23020
+ const tunnel = await promptForTunnelName(hostname, hostname.split(".")[0]);
23021
+ return { hostname, tunnel };
22813
23022
  }
22814
23023
  if (!REGISTRAR_IS_LIVE && !origin && !process.env.FRIZZ_REGISTRAR) {
22815
23024
  throw new Error(
@@ -22912,6 +23121,420 @@ function startTunnel(config, onExit, onError, home = homedir3(), runToken) {
22912
23121
  };
22913
23122
  }
22914
23123
 
23124
+ // src/remote-controller.ts
23125
+ function createRemoteController(options2) {
23126
+ const home = options2.home ?? homedir4();
23127
+ let config = null;
23128
+ let transport = null;
23129
+ const stopTransport = () => {
23130
+ transport?.stop();
23131
+ transport = null;
23132
+ };
23133
+ const startTransport = async (next, justClaimed) => {
23134
+ if (isExternalConfig(next)) return;
23135
+ if (isRelayConfig(next)) {
23136
+ if (!justClaimed) {
23137
+ await resolveRunToken(next, options2.port, home, (message) => {
23138
+ options2.log.warn("relay", message);
23139
+ options2.say(message);
23140
+ });
23141
+ }
23142
+ transport = await startRelay(next, options2.port, home, (message) => options2.log.info("relay", message));
23143
+ options2.log.info("relay", `serving ${next.hostname} through the Frizz relay`);
23144
+ return;
23145
+ }
23146
+ const runToken = isClaimedConfig(next) ? justClaimed ? readTunnelToken(home) : await resolveRunToken(next, options2.port, home, (message) => {
23147
+ options2.log.warn("tunnel", message);
23148
+ options2.say(message);
23149
+ }) : null;
23150
+ transport = startTunnel(
23151
+ next,
23152
+ (code) => {
23153
+ if (code === 0 || code === null) return;
23154
+ options2.log.error("tunnel", `cloudflared exited with code ${code}; the public hostname is now unreachable`);
23155
+ },
23156
+ (message) => {
23157
+ options2.log.error("tunnel", message);
23158
+ options2.say(message);
23159
+ },
23160
+ home,
23161
+ runToken ?? void 0
23162
+ );
23163
+ options2.log.info("tunnel", `running cloudflared for ${next.hostname}`);
23164
+ };
23165
+ const originOf = (next) => next ? `https://${next.hostname}` : void 0;
23166
+ return {
23167
+ current: () => config,
23168
+ origin: () => originOf(config),
23169
+ async serveSaved() {
23170
+ const saved = readCloudConfig(home);
23171
+ if (!saved) return;
23172
+ const reconciled = await reconcileCloudConfig(saved, options2.port, home, (message) => {
23173
+ options2.log.warn("cloud", message);
23174
+ options2.say(message);
23175
+ });
23176
+ const justClaimed = reconciled !== saved;
23177
+ if (justClaimed) writeCloudConfig(reconciled, home);
23178
+ await startTransport(reconciled, justClaimed);
23179
+ config = reconciled;
23180
+ options2.host.setPublicOrigin(originOf(reconciled));
23181
+ },
23182
+ async apply(next, applyOptions = {}) {
23183
+ stopTransport();
23184
+ options2.host.setPublicOrigin(void 0);
23185
+ if (!next) {
23186
+ deleteCloudConfig(home);
23187
+ config = null;
23188
+ return;
23189
+ }
23190
+ writeCloudConfig(next, home);
23191
+ try {
23192
+ await startTransport(next, applyOptions.justClaimed ?? false);
23193
+ } catch (error) {
23194
+ config = null;
23195
+ throw error;
23196
+ }
23197
+ config = next;
23198
+ options2.host.setPublicOrigin(originOf(next));
23199
+ },
23200
+ stop: stopTransport
23201
+ };
23202
+ }
23203
+
23204
+ // src/remote-detect.ts
23205
+ import { execFile as execFile3 } from "node:child_process";
23206
+ var PROBE_TIMEOUT_MS = 4e3;
23207
+ function run2(command, args2) {
23208
+ return new Promise((resolve5) => {
23209
+ execFile3(command, args2, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
23210
+ resolve5(error ? null : stdout);
23211
+ });
23212
+ });
23213
+ }
23214
+ async function probeGithub() {
23215
+ const version = await run2("gh", ["--version"]);
23216
+ if (version === null) return { login: null, installed: false };
23217
+ const login = await run2("gh", ["api", "user", "--jq", ".login"]);
23218
+ return { login: login?.trim() || null, installed: true };
23219
+ }
23220
+ async function probeCloudflared() {
23221
+ const out = await run2("cloudflared", ["--version"]);
23222
+ const version = out?.match(/\d+\.\d+\.\d+/)?.[0] ?? (out ? out.trim() : null);
23223
+ return { version };
23224
+ }
23225
+ async function probeTailscale() {
23226
+ const out = await run2("tailscale", ["status", "--json"]);
23227
+ if (out === null) return { installed: await run2("tailscale", ["--version"]) !== null, dnsName: null };
23228
+ try {
23229
+ const status = JSON.parse(out);
23230
+ const dnsName = status.Self?.DNSName?.replace(/\.$/, "") || null;
23231
+ return { installed: true, dnsName };
23232
+ } catch {
23233
+ return { installed: true, dnsName: null };
23234
+ }
23235
+ }
23236
+
23237
+ // src/remote-pane.ts
23238
+ var CHOICES = [
23239
+ { kind: "frizz", title: "frizz.sh name", blurb: "<name>.frizz.sh \u2014 nothing to install; needs the GitHub CLI" },
23240
+ { kind: "cloudflare", title: "Cloudflare Tunnel", blurb: "a domain you own on Cloudflare; cloudflared on this machine" },
23241
+ { kind: "tailscale", title: "Tailscale", blurb: "your tailnet; tailscale serve does the TLS" },
23242
+ { kind: "other", title: "Something else", blurb: "any proxy or tunnel you run \u2014 tell Frizz its address" },
23243
+ { kind: "off", title: "Off", blurb: "loopback only" }
23244
+ ];
23245
+ var ENTER = /* @__PURE__ */ new Set(["\r", "\n"]);
23246
+ var BACKSPACE = /* @__PURE__ */ new Set(["\x7F", "\b"]);
23247
+ var ESC = "\x1B";
23248
+ var UP = "\x1B[A";
23249
+ var DOWN = "\x1B[B";
23250
+ var TAB = " ";
23251
+ var SHIFT_TAB = "\x1B[Z";
23252
+ function kindOf(config) {
23253
+ if (!config) return "off";
23254
+ if (isClaimedConfig(config)) return "frizz";
23255
+ if (isExternalConfig(config)) return config.provider === "tailscale" ? "tailscale" : "other";
23256
+ return "cloudflare";
23257
+ }
23258
+ function wrap(text, width = 74) {
23259
+ const lines = [];
23260
+ for (const paragraph of text.split("\n")) {
23261
+ let line = "";
23262
+ for (const word of paragraph.split(" ")) {
23263
+ if (line && line.length + 1 + word.length > width) {
23264
+ lines.push(line);
23265
+ line = word;
23266
+ } else line = line ? `${line} ${word}` : word;
23267
+ }
23268
+ lines.push(line);
23269
+ }
23270
+ return lines;
23271
+ }
23272
+ function createRemotePane(options2) {
23273
+ const output = options2.output ?? process.stdout;
23274
+ let open = false;
23275
+ let screen = { name: "menu", index: 0 };
23276
+ let github = null;
23277
+ let cloudflared = null;
23278
+ let tailscale = null;
23279
+ const write = (lines) => {
23280
+ if (!open) return;
23281
+ output.write(CLEAR);
23282
+ output.write("\n");
23283
+ for (const line of lines) output.write(` ${line}
23284
+ `);
23285
+ };
23286
+ const check = (ok, text) => ok ? `${text} \u2713` : text;
23287
+ const paint = () => {
23288
+ if (!open) return;
23289
+ const s = screen;
23290
+ if (s.name === "menu") {
23291
+ const current = kindOf(options2.current());
23292
+ write([
23293
+ "Reach this board from anywhere",
23294
+ "",
23295
+ ...wrap(
23296
+ "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."
23297
+ ),
23298
+ "",
23299
+ ...CHOICES.map((choice, index) => {
23300
+ const marker = index === s.index ? "\u276F" : " ";
23301
+ const title = choice.title.padEnd(18);
23302
+ const now = choice.kind === current ? ` ${DIM}(current)${RESET2}` : "";
23303
+ return `${marker} ${title}${DIM}${choice.blurb}${RESET2}${now}`;
23304
+ }),
23305
+ "",
23306
+ `${DIM}\u2191\u2193 move \xB7 enter choose \xB7 esc back${RESET2}`
23307
+ ]);
23308
+ return;
23309
+ }
23310
+ if (s.name === "form") {
23311
+ const head = formHead(s.kind);
23312
+ const fields = s.fields.map((field, index) => {
23313
+ const focused = index === s.focus;
23314
+ const shown = field.value || (field.placeholder ? `${DIM}${field.placeholder}${RESET2}` : "");
23315
+ const cursor = focused ? "\u2588" : "";
23316
+ return `${field.label.padEnd(10)} ${shown}${cursor}`;
23317
+ });
23318
+ write([...head, "", ...fields, ...s.note ? ["", s.note] : [], "", `${DIM}enter ${s.kind === "frizz" ? "claim" : "save"} \xB7 tab next field \xB7 esc back${RESET2}`]);
23319
+ return;
23320
+ }
23321
+ if (s.name === "busy") {
23322
+ write([s.message, "", `${DIM}working\u2026${RESET2}`]);
23323
+ return;
23324
+ }
23325
+ if (s.name === "done") {
23326
+ const lines = [s.message, ""];
23327
+ if (s.link) {
23328
+ for (const row of renderQrLines(s.link.url)) lines.push(row);
23329
+ lines.push("", s.link.url, "", `${DIM}Scan to sign in on a phone. Single use, expires in 5 minutes. Press L later for another.${RESET2}`);
23330
+ }
23331
+ lines.push("", `${DIM}press any key to return${RESET2}`);
23332
+ write(lines);
23333
+ return;
23334
+ }
23335
+ write([`Could not apply that: ${s.message}`, "", `${DIM}press any key to go back${RESET2}`]);
23336
+ };
23337
+ const formHead = (kind) => {
23338
+ if (kind === "frizz") {
23339
+ const gh = github === "pending" || github === null ? `${DIM}GitHub CLI checking\u2026${RESET2}` : !github.installed ? "GitHub CLI not installed \u2014 see https://cli.github.com, then `gh auth login`" : github.login ? check(true, `GitHub CLI signed in as ${github.login}`) : "GitHub CLI not signed in \u2014 run `gh auth login` in another terminal";
23340
+ return [
23341
+ "frizz.sh name",
23342
+ "",
23343
+ ...wrap(
23344
+ "Claims <name>.frizz.sh for your GitHub account. The board dials out to frizz.sh \u2014 no port, no DNS record, no tunnel binary. One name per account; a name nobody runs for 30 days is released."
23345
+ ),
23346
+ "",
23347
+ gh
23348
+ ];
23349
+ }
23350
+ if (kind === "cloudflare") {
23351
+ const cf = cloudflared === "pending" || cloudflared === null ? `${DIM}cloudflared checking\u2026${RESET2}` : cloudflared.version ? check(true, `cloudflared found, ${cloudflared.version}`) : "cloudflared not found \u2014 install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
23352
+ return [
23353
+ "Cloudflare Tunnel",
23354
+ "",
23355
+ "Needs a domain on Cloudflare and cloudflared on this machine.",
23356
+ cf,
23357
+ "",
23358
+ "Create the tunnel and its DNS record once, in another terminal:",
23359
+ "",
23360
+ " cloudflared tunnel login",
23361
+ " cloudflared tunnel create my-board",
23362
+ " cloudflared tunnel route dns my-board board.example.com",
23363
+ "",
23364
+ ...wrap(
23365
+ `Then name them here. Frizz writes ~/.cloudflared/frizz.yml (ingress to http://127.0.0.1:${options2.port}) and runs the tunnel beside the board, so the two share a lifetime.`
23366
+ )
23367
+ ];
23368
+ }
23369
+ if (kind === "tailscale") {
23370
+ const ts = tailscale === "pending" || tailscale === null ? `${DIM}tailscale checking\u2026${RESET2}` : !tailscale.installed ? "tailscale not found \u2014 install it from https://tailscale.com/download" : tailscale.dnsName ? check(true, `tailscale found, this machine is ${tailscale.dnsName}`) : "tailscale found, but the daemon did not answer \u2014 is it signed in?";
23371
+ return [
23372
+ "Tailscale",
23373
+ "",
23374
+ ...wrap("Serves the board on your tailnet. Tailscale terminates TLS and only devices signed into the tailnet can reach the name."),
23375
+ ts,
23376
+ "",
23377
+ "Run once, in another terminal:",
23378
+ "",
23379
+ ` tailscale serve --bg ${options2.port}`,
23380
+ "",
23381
+ "Tailscale answers at the address below \u2014 that is the origin Frizz accepts."
23382
+ ];
23383
+ }
23384
+ return [
23385
+ "Something else",
23386
+ "",
23387
+ ...wrap(
23388
+ `Terminate TLS wherever you like, proxy to http://127.0.0.1:${options2.port}, and give Frizz the exact origin a browser will show \u2014 scheme and host, no path. Frizz answers only to that Host and prints a single-use sign-in link.`
23389
+ )
23390
+ ];
23391
+ };
23392
+ const openForm = (kind) => {
23393
+ const current = options2.current();
23394
+ const same = kindOf(current) === kind ? current : null;
23395
+ let fields;
23396
+ if (kind === "frizz") {
23397
+ github = "pending";
23398
+ void options2.probes.github().then((result) => {
23399
+ github = result;
23400
+ if (screen.name === "form" && screen.kind === "frizz" && !screen.fields[0].value && result.login) {
23401
+ screen.fields[0].placeholder = result.login;
23402
+ }
23403
+ paint();
23404
+ });
23405
+ fields = [{ label: "Name", value: same?.claim ?? "" }];
23406
+ } else if (kind === "cloudflare") {
23407
+ cloudflared = "pending";
23408
+ void options2.probes.cloudflared().then((result) => {
23409
+ cloudflared = result;
23410
+ paint();
23411
+ });
23412
+ fields = [
23413
+ { label: "Hostname", value: same?.hostname ?? "", placeholder: "board.example.com" },
23414
+ { label: "Tunnel", value: same?.tunnel ?? "", placeholder: "my-board" }
23415
+ ];
23416
+ } else if (kind === "tailscale") {
23417
+ tailscale = "pending";
23418
+ void options2.probes.tailscale().then((result) => {
23419
+ tailscale = result;
23420
+ if (screen.name === "form" && screen.kind === "tailscale" && !screen.fields[0].value && result.dnsName) {
23421
+ screen.fields[0].placeholder = `https://${result.dnsName}`;
23422
+ }
23423
+ paint();
23424
+ });
23425
+ fields = [{ label: "Origin", value: same ? `https://${same.hostname}` : "", placeholder: "https://mac-mini.your-tailnet.ts.net" }];
23426
+ } else {
23427
+ fields = [{ label: "Origin", value: same ? `https://${same.hostname}` : "", placeholder: "https://board.example.com" }];
23428
+ }
23429
+ screen = { name: "form", kind, fields, focus: 0 };
23430
+ paint();
23431
+ };
23432
+ const valueOf = (field) => field.value || field.placeholder || "";
23433
+ const submit = async (form) => {
23434
+ const back = form;
23435
+ try {
23436
+ let next;
23437
+ let justClaimed = false;
23438
+ if (form.kind === "frizz") {
23439
+ const name = valueOf(form.fields[0]).trim();
23440
+ if (!name) throw new Error("a name is needed");
23441
+ screen = { name: "busy", message: `claiming ${name}.frizz.sh\u2026` };
23442
+ paint();
23443
+ next = await options2.claim(name);
23444
+ justClaimed = true;
23445
+ } else if (form.kind === "cloudflare") {
23446
+ const hostname = normalizeHostname(valueOf(form.fields[0]));
23447
+ const tunnel = valueOf(form.fields[1]).trim();
23448
+ if (!tunnel) throw new Error("the tunnel's name is needed");
23449
+ next = { hostname, tunnel };
23450
+ } else {
23451
+ const hostname = normalizeHostname(valueOf(form.fields[0]));
23452
+ next = { hostname, serve: "external", provider: form.kind === "tailscale" ? "tailscale" : "other" };
23453
+ }
23454
+ screen = { name: "busy", message: `serving ${next.hostname}\u2026` };
23455
+ paint();
23456
+ await options2.apply(next, { justClaimed });
23457
+ options2.onChanged?.(next);
23458
+ screen = { name: "done", message: `Serving https://${next.hostname} (${describeCloudConfig(next)}).`, link: options2.issueLink(), config: next };
23459
+ } catch (error) {
23460
+ screen = { name: "error", message: error instanceof Error ? error.message : String(error), back };
23461
+ }
23462
+ paint();
23463
+ };
23464
+ const turnOff = async () => {
23465
+ screen = { name: "busy", message: "back to loopback only\u2026" };
23466
+ paint();
23467
+ try {
23468
+ await options2.apply(null);
23469
+ options2.onChanged?.(null);
23470
+ screen = { name: "done", message: "Loopback only. This board is reachable from this machine alone.", link: null, config: null };
23471
+ } catch (error) {
23472
+ screen = { name: "error", message: error instanceof Error ? error.message : String(error), back: { name: "menu", index: CHOICES.length - 1 } };
23473
+ }
23474
+ paint();
23475
+ };
23476
+ return {
23477
+ open() {
23478
+ open = true;
23479
+ const current = kindOf(options2.current());
23480
+ screen = { name: "menu", index: Math.max(0, CHOICES.findIndex((choice) => choice.kind === current)) };
23481
+ output.write(ALT_SCREEN_ON);
23482
+ output.write(HIDE_CURSOR);
23483
+ paint();
23484
+ return true;
23485
+ },
23486
+ key(key) {
23487
+ const s = screen;
23488
+ if (s.name === "busy") return "keep";
23489
+ if (s.name === "done") return "close";
23490
+ if (s.name === "error") {
23491
+ screen = s.back;
23492
+ paint();
23493
+ return "keep";
23494
+ }
23495
+ if (s.name === "menu") {
23496
+ if (key === ESC || key === "q") return "close";
23497
+ if (key === UP || key === "k") s.index = (s.index + CHOICES.length - 1) % CHOICES.length;
23498
+ else if (key === DOWN || key === "j") s.index = (s.index + 1) % CHOICES.length;
23499
+ else if (/^[1-5]$/.test(key)) s.index = Number(key) - 1;
23500
+ else if (ENTER.has(key)) {
23501
+ const choice = CHOICES[s.index];
23502
+ if (choice.kind === "off") void turnOff();
23503
+ else openForm(choice.kind);
23504
+ return "keep";
23505
+ }
23506
+ paint();
23507
+ return "keep";
23508
+ }
23509
+ if (key === ESC) {
23510
+ screen = { name: "menu", index: CHOICES.findIndex((choice) => choice.kind === s.kind) };
23511
+ paint();
23512
+ return "keep";
23513
+ }
23514
+ const field = s.fields[s.focus];
23515
+ if (ENTER.has(key)) {
23516
+ if (s.focus < s.fields.length - 1 && valueOf(field)) s.focus += 1;
23517
+ else void submit(s);
23518
+ } else if (key === TAB || key === DOWN) s.focus = (s.focus + 1) % s.fields.length;
23519
+ else if (key === SHIFT_TAB || key === UP) s.focus = (s.focus + s.fields.length - 1) % s.fields.length;
23520
+ else if (BACKSPACE.has(key)) field.value = field.value.slice(0, -1);
23521
+ else if (key.length === 1 && key >= " ") field.value += key;
23522
+ else if (!key.startsWith(ESC)) field.value += key.replace(/[\r\n\t]/g, "");
23523
+ paint();
23524
+ return "keep";
23525
+ },
23526
+ close() {
23527
+ if (!open) return;
23528
+ open = false;
23529
+ output.write(ALT_SCREEN_OFF);
23530
+ output.write(SHOW_CURSOR);
23531
+ }
23532
+ };
23533
+ }
23534
+
23535
+ // src/production.ts
23536
+ init_local_origin();
23537
+
22915
23538
  // src/readout.ts
22916
23539
  var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
22917
23540
  var SYNC_BEGIN = "\x1B[?2026h";
@@ -23259,32 +23882,26 @@ init_project_registry();
23259
23882
  import { execFileSync as execFileSync3 } from "node:child_process";
23260
23883
  import { createServer as createServer2 } from "node:net";
23261
23884
  import { mkdirSync as mkdirSync10, readFileSync as readFileSync11, realpathSync as realpathSync5, writeFileSync as writeFileSync10 } from "node:fs";
23262
- import { homedir as homedir9, hostname as osHostname, networkInterfaces } from "node:os";
23885
+ import { homedir as homedir10 } from "node:os";
23263
23886
  import { basename as basename5, join as join13, resolve as resolve3 } from "node:path";
23264
23887
  import { setTimeout as delay2 } from "node:timers/promises";
23265
23888
  var PORT_SCAN_COUNT = 100;
23266
23889
  var LAUNCH_TIMEOUT_MS = 3e4;
23267
23890
  var LAUNCH_HARD_TIMEOUT_MS = 10 * 6e4;
23268
- function looksLikeBindHost(value) {
23269
- if (value === void 0 || value.startsWith("-")) return false;
23270
- try {
23271
- normalizeBindHost(value);
23272
- return true;
23273
- } catch {
23274
- return false;
23275
- }
23891
+ var RETIRED_NETWORK_FLAGS = ["--host", "--allowed-host", "--public-origin", "--cloud"];
23892
+ function retiredFlagMessage(flag) {
23893
+ 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 frizz.sh name, a Cloudflare Tunnel, Tailscale or a proxy of your own. The choice is remembered, so a plain launch serves it.`;
23276
23894
  }
23277
23895
  function parseCliArgs(argv) {
23278
23896
  const args2 = new Set(argv);
23279
- let upCommand = false;
23280
23897
  let rawPort;
23281
- let rawHost;
23282
- let rawPublicOrigin;
23283
- const rawAllowedHosts = [];
23898
+ let rawSignOut;
23284
23899
  const consumed = /* @__PURE__ */ new Set();
23285
23900
  for (let index = 0; index < argv.length; index++) {
23286
23901
  const arg = argv[index];
23287
23902
  if (consumed.has(index)) continue;
23903
+ const retired = arg === "up" ? "up" : RETIRED_NETWORK_FLAGS.find((flag) => arg === flag || arg.startsWith(`${flag}=`));
23904
+ if (retired) throw new Error(retiredFlagMessage(retired));
23288
23905
  if (arg === "--port") {
23289
23906
  rawPort = argv[++index];
23290
23907
  if (rawPort === void 0 || rawPort.startsWith("-"))
@@ -23296,49 +23913,21 @@ function parseCliArgs(argv) {
23296
23913
  rawPort = arg.slice("--port=".length);
23297
23914
  continue;
23298
23915
  }
23299
- if (arg === "--host") {
23300
- if (looksLikeBindHost(argv[index + 1])) {
23301
- rawHost = argv[++index];
23302
- consumed.add(index);
23303
- } else {
23304
- rawHost = ALL_INTERFACES_BIND_HOST;
23305
- }
23306
- continue;
23307
- }
23308
- if (arg.startsWith("--host=")) {
23309
- rawHost = arg.slice("--host=".length);
23310
- continue;
23311
- }
23312
- if (arg === "--allowed-host") {
23313
- const value = argv[++index];
23916
+ if (arg === "--sign-out") {
23917
+ const value = argv[index + 1];
23314
23918
  if (value === void 0 || value.startsWith("-"))
23315
- throw new Error("--allowed-host requires a value");
23316
- consumed.add(index);
23317
- rawAllowedHosts.push(value);
23318
- continue;
23319
- }
23320
- if (arg.startsWith("--allowed-host=")) {
23321
- rawAllowedHosts.push(arg.slice("--allowed-host=".length));
23322
- continue;
23323
- }
23324
- if (arg === "--public-origin") {
23325
- const value = argv[++index];
23326
- if (value === void 0 || value.startsWith("-"))
23327
- throw new Error("--public-origin requires a value");
23919
+ throw new Error("--sign-out requires a device id, or `all` to sign out every device");
23920
+ index++;
23328
23921
  consumed.add(index);
23329
- rawPublicOrigin = value;
23922
+ rawSignOut = value;
23330
23923
  continue;
23331
23924
  }
23332
- if (arg.startsWith("--public-origin=")) {
23333
- rawPublicOrigin = arg.slice("--public-origin=".length);
23925
+ if (arg.startsWith("--sign-out=")) {
23926
+ rawSignOut = arg.slice("--sign-out=".length);
23927
+ if (!rawSignOut) throw new Error("--sign-out requires a device id, or `all` to sign out every device");
23334
23928
  continue;
23335
23929
  }
23336
23930
  if (arg.startsWith("-")) continue;
23337
- if (arg === "up") {
23338
- upCommand = true;
23339
- consumed.add(index);
23340
- continue;
23341
- }
23342
23931
  throw new Error(
23343
23932
  `unexpected argument: ${arg} \u2014 Frizz takes no repository path, because one server serves every project on this machine. cd into the directory you want and run it with no arguments.`
23344
23933
  );
@@ -23349,9 +23938,6 @@ function parseCliArgs(argv) {
23349
23938
  if (!Number.isInteger(port) || port < 1 || port > 65535)
23350
23939
  throw new Error(`invalid --port value: ${rawPort}`);
23351
23940
  }
23352
- if ((upCommand || args2.has("--cloud")) && rawPublicOrigin !== void 0)
23353
- throw new Error("choose either up (the saved public hostname) or --public-origin, not both");
23354
- const host = rawHost === void 0 ? void 0 : normalizeBindHost(rawHost);
23355
23941
  const known = /* @__PURE__ */ new Set([
23356
23942
  "--app",
23357
23943
  "--no-app",
@@ -23360,15 +23946,13 @@ function parseCliArgs(argv) {
23360
23946
  "--stop",
23361
23947
  "--status",
23362
23948
  "--link",
23363
- "--cloud",
23949
+ "--sessions",
23950
+ "--sign-out",
23364
23951
  "--help",
23365
23952
  "-h",
23366
23953
  "--dev",
23367
23954
  "--prod",
23368
23955
  "--port",
23369
- "--host",
23370
- "--allowed-host",
23371
- "--public-origin",
23372
23956
  "--debug"
23373
23957
  ]);
23374
23958
  for (let index = 0; index < argv.length; index++) {
@@ -23378,7 +23962,11 @@ function parseCliArgs(argv) {
23378
23962
  index++;
23379
23963
  continue;
23380
23964
  }
23381
- if (arg.startsWith("--port=") || arg.startsWith("--host=") || arg.startsWith("--allowed-host=") || arg.startsWith("--public-origin=")) continue;
23965
+ if (arg === "--sign-out") {
23966
+ index++;
23967
+ continue;
23968
+ }
23969
+ if (arg.startsWith("--port=") || arg.startsWith("--sign-out=")) continue;
23382
23970
  if (!known.has(arg)) throw new Error(`unknown option: ${arg}`);
23383
23971
  }
23384
23972
  if (args2.has("--detach"))
@@ -23392,65 +23980,19 @@ function parseCliArgs(argv) {
23392
23980
  foreground: true,
23393
23981
  stop: args2.has("--stop"),
23394
23982
  link: args2.has("--link"),
23395
- cloud: upCommand || args2.has("--cloud"),
23983
+ sessions: args2.has("--sessions"),
23984
+ ...rawSignOut !== void 0 ? { signOut: rawSignOut } : {},
23396
23985
  status: args2.has("--status"),
23397
23986
  help: args2.has("--help") || args2.has("-h"),
23398
23987
  dev: args2.has("--dev"),
23399
23988
  debug: args2.has("--debug"),
23400
- port,
23401
- host,
23402
- allowedHosts: normalizeAllowedHosts(rawAllowedHosts),
23403
- ...rawPublicOrigin === void 0 ? {} : { publicOrigin: normalizePublicOrigin(rawPublicOrigin) }
23404
- };
23405
- }
23406
- var EXPOSED_WARNING = "Anyone who can reach this address can run commands on this machine as you. Frizz has no login \u2014 only do this on a network you trust.";
23407
- var REUSED_NETWORK_FLAGS_WARNING = "This joined the Frizz already running on this machine, so --host/--public-origin were ignored. Stop it first (frizz --stop) and relaunch with the flag to change how the board is reached.";
23408
- var PUBLIC_ORIGIN_WARNING = "Frizz has no login of its own, so whatever sits in front of this origin IS the access control. Require authentication there (Cloudflare Access, Tailscale) \u2014 an unauthenticated tunnel lets anyone with the URL run commands on this machine as you.";
23409
- function resolveBindSelection(options2, env = process.env, machineName = osHostname()) {
23410
- const fromEnv = env.FRIZZ_HOST?.trim();
23411
- const host = options2.host ?? (fromEnv ? normalizeBindHost(fromEnv) : LOOPBACK_BIND_HOST);
23412
- const exposed = bindHostIsExposed(host);
23413
- const ownNames = exposed ? machineHostNames(machineName) : [];
23414
- const allowedHosts = normalizeAllowedHosts([
23415
- ...options2.allowedHosts,
23416
- ...env.FRIZZ_ALLOWED_HOSTS ? [env.FRIZZ_ALLOWED_HOSTS] : [],
23417
- ...ownNames
23418
- ]);
23419
- const publicOriginRaw = options2.publicOrigin ?? env.FRIZZ_PUBLIC_ORIGIN?.trim();
23420
- const publicOrigin = publicOriginRaw ? normalizePublicOrigin(publicOriginRaw) : void 0;
23421
- return {
23422
- host,
23423
- exposed,
23424
- allowedHosts,
23425
- ...ownNames.length > 0 ? { hostname: ownNames[0] } : {},
23426
- ...publicOrigin ? { publicOrigin } : {}
23989
+ port
23427
23990
  };
23428
23991
  }
23429
23992
  function boardAddress(url) {
23430
23993
  return url.includes("?") || url.endsWith("/") ? url : `${url}/`;
23431
23994
  }
23432
- function networkUrls(port, host, interfaces = networkInterfaces, hostname) {
23433
- if (!bindHostIsExposed(host)) return [];
23434
- const urls = hostname ? [`http://${hostname}:${port}`] : [];
23435
- const wildcard = host === ALL_INTERFACES_BIND_HOST || host === "::";
23436
- const push = (address, family) => {
23437
- if (address.startsWith("fe80:")) return;
23438
- urls.push(family === "IPv6" ? `http://[${address}]:${port}` : `http://${address}:${port}`);
23439
- };
23440
- if (!wildcard) {
23441
- push(host, host.includes(":") ? "IPv6" : "IPv4");
23442
- return urls;
23443
- }
23444
- for (const entries of Object.values(interfaces())) {
23445
- for (const entry of entries ?? []) {
23446
- if (entry.internal) continue;
23447
- if (host === ALL_INTERFACES_BIND_HOST && entry.family !== "IPv4") continue;
23448
- push(entry.address, entry.family);
23449
- }
23450
- }
23451
- return urls;
23452
- }
23453
- function resolveLaunchIntent(cwd = process.cwd(), home = homedir9(), env = process.env) {
23995
+ function resolveLaunchIntent(cwd = process.cwd(), home = homedir10(), env = process.env) {
23454
23996
  const candidate = realpathSync5(discoverProjectRoot(cwd, home));
23455
23997
  const known = isExistingProjectRoot(candidate) || findByPath(candidate, home) !== void 0;
23456
23998
  if ((known || hasProjectMarker(candidate)) && !isHomeDirectory(candidate, home))
@@ -23470,7 +24012,7 @@ function mostRecentProject(home, env) {
23470
24012
  }
23471
24013
  return void 0;
23472
24014
  }
23473
- function resolveWorkspace(cwd = process.cwd(), home = homedir9(), env = process.env) {
24015
+ function resolveWorkspace(cwd = process.cwd(), home = homedir10(), env = process.env) {
23474
24016
  let gitRoot;
23475
24017
  try {
23476
24018
  gitRoot = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
@@ -23745,12 +24287,15 @@ function devCrashRetryDelay(attempt) {
23745
24287
  return Math.min(DEV_CRASH_RETRY_BASE_MS * 2 ** exponent, DEV_CRASH_RETRY_MAX_MS);
23746
24288
  }
23747
24289
  var SUPERVISOR_ESCALATE_GRACE_MS = 500;
24290
+ var SUPERVISOR_DRAIN_DEADLINE_MS = CHILD_STOP_TIMEOUT_MS + 5e3;
23748
24291
  function stripPrefix(line) {
23749
24292
  return line.startsWith("[frizz] ") ? line.slice("[frizz] ".length) : line;
23750
24293
  }
23751
24294
  function createSupervisorShutdownHandler(options2) {
23752
24295
  const now = options2.now ?? (() => Date.now());
23753
24296
  const escalateAfterMs = options2.escalateAfterMs ?? SUPERVISOR_ESCALATE_GRACE_MS;
24297
+ const drainDeadlineMs = options2.drainDeadlineMs ?? SUPERVISOR_DRAIN_DEADLINE_MS;
24298
+ const scheduleForce = options2.scheduleForce ?? ((callback, delayMs) => setTimeout(callback, delayMs));
23754
24299
  let startedAt = 0;
23755
24300
  let stopping = false;
23756
24301
  let decided = false;
@@ -23764,20 +24309,27 @@ function createSupervisorShutdownHandler(options2) {
23764
24309
  }
23765
24310
  options2.exit(code);
23766
24311
  };
24312
+ const abandon = (reason) => {
24313
+ options2.error?.(`[frizz] ${reason} \u2014 abandoning the graceful drain`);
24314
+ try {
24315
+ options2.force?.();
24316
+ } catch (error) {
24317
+ options2.error?.(`[frizz] forced supervisor stop failed: ${error instanceof Error ? error.message : error}`);
24318
+ }
24319
+ decide(1);
24320
+ };
23767
24321
  return () => {
23768
24322
  if (stopping) {
23769
24323
  if (decided || now() - startedAt < escalateAfterMs) return;
23770
- options2.error?.("[frizz] second stop signal \u2014 abandoning the graceful drain");
23771
- try {
23772
- options2.force?.();
23773
- } catch (error) {
23774
- options2.error?.(`[frizz] forced supervisor stop failed: ${error instanceof Error ? error.message : error}`);
23775
- }
23776
- decide(1);
24324
+ abandon("second stop signal");
23777
24325
  return;
23778
24326
  }
23779
24327
  stopping = true;
23780
24328
  startedAt = now();
24329
+ options2.onStop?.();
24330
+ scheduleForce(() => {
24331
+ if (!decided) abandon(`graceful stop did not finish in ${drainDeadlineMs}ms`);
24332
+ }, drainDeadlineMs).unref?.();
23781
24333
  void options2.close().then(
23782
24334
  () => decide(0),
23783
24335
  (error) => {
@@ -23987,6 +24539,7 @@ var Supervisor = class {
23987
24539
  publicOrigin: opts.publicOrigin,
23988
24540
  onCodeConsumed: opts.onCodeConsumed,
23989
24541
  sessionKey: opts.sessionKey,
24542
+ sessionDirectory: opts.sessionDirectory,
23990
24543
  childPort: () => this.childPort,
23991
24544
  restart: () => this.restartFromBrowser(),
23992
24545
  updateRestart: this.updateRestart ? () => this.updateFromBrowser() : void 0,
@@ -24012,6 +24565,9 @@ var Supervisor = class {
24012
24565
  return this.boot;
24013
24566
  }
24014
24567
  /** Mint a single-use access link for the public origin, or null when none is declared. */
24568
+ setPublicOrigin(origin) {
24569
+ this.publicProxy.setPublicOrigin(origin);
24570
+ }
24015
24571
  issueAccessLink() {
24016
24572
  const code = this.publicProxy.issueAccessCode();
24017
24573
  if (!code) return null;
@@ -24082,7 +24638,7 @@ var Supervisor = class {
24082
24638
  if (this.closed) return;
24083
24639
  this.reloadLauncher ||= reloadLauncher;
24084
24640
  if (this.debounce) clearTimeout(this.debounce);
24085
- const run2 = () => {
24641
+ const run3 = () => {
24086
24642
  this.debounce = null;
24087
24643
  const scope = this.reloadLauncher ? "control plane + launcher" : "control plane";
24088
24644
  this.logLine(
@@ -24091,8 +24647,8 @@ var Supervisor = class {
24091
24647
  this.writeStatus("restarting", reason);
24092
24648
  void this.restart();
24093
24649
  };
24094
- if (immediate) run2();
24095
- else this.debounce = setTimeout(run2, delayMs);
24650
+ if (immediate) run3();
24651
+ else this.debounce = setTimeout(run3, delayMs);
24096
24652
  }
24097
24653
  async restart() {
24098
24654
  if (this.restartRunning) {
@@ -24479,7 +25035,7 @@ async function startDevSupervisor(opts) {
24479
25035
  }
24480
25036
 
24481
25037
  // src/production-update.ts
24482
- import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
25038
+ import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
24483
25039
  var PRODUCTION_REEXEC_FLAG = "--_frizz-production-reexec";
24484
25040
  function compareReleaseVersions(a, b) {
24485
25041
  const parse2 = (value) => {
@@ -24528,7 +25084,7 @@ function handoffToRegistrySuccessor(plan, request, adapter) {
24528
25084
  var npmRegistryReleaseAdapter = {
24529
25085
  latestVersion(packageName) {
24530
25086
  return new Promise((resolveVersion, reject) => {
24531
- execFile3("npm", ["view", `${packageName}@latest`, "version", "--json"], { encoding: "utf8" }, (error, stdout) => {
25087
+ execFile4("npm", ["view", `${packageName}@latest`, "version", "--json"], { encoding: "utf8" }, (error, stdout) => {
24532
25088
  if (error) return reject(new Error(`could not check npm for ${packageName}: ${error.message}`));
24533
25089
  try {
24534
25090
  const parsed = JSON.parse(stdout);
@@ -24649,11 +25205,7 @@ if (options.help) {
24649
25205
  console.log(
24650
25206
  `Frizz production launcher
24651
25207
 
24652
- Usage: npx ${PACKAGE_NAME} [up] [options]
24653
-
24654
- Commands:
24655
- up serve at the saved public hostname and run its tunnel; asked once,
24656
- then remembered
25208
+ Usage: npx ${PACKAGE_NAME} [options]
24657
25209
 
24658
25210
  Run it in the directory you want to work in. One server serves EVERY project on this machine,
24659
25211
  each at its own /project/<name> URL, so a second run joins the one already going. Runs the
@@ -24663,45 +25215,23 @@ for a source checkout.
24663
25215
  Options:
24664
25216
  --no-app print the URL without opening a browser
24665
25217
  --port <port> request a fixed port for a new workspace server
24666
- --host [address] serve on a network address instead of loopback (bare --host means 0.0.0.0)
24667
- --allowed-host <name> with --host, also accept this DNS name as the board's address (repeatable)
24668
- --public-origin <url> serve behind a proxy/tunnel reachable at this exact origin
24669
- --cloud alias of up, for saved commands
24670
25218
  --link print a fresh single-use access link for the running board
24671
25219
  --debug stream the full event feed to the terminal instead of the compact readout
24672
25220
  -h, --help show this help
24673
25221
 
24674
- Environment:
24675
- FRIZZ_HOST same as --host
24676
- FRIZZ_ALLOWED_HOSTS same as --allowed-host, comma separated
24677
- FRIZZ_PUBLIC_ORIGIN same as --public-origin
24678
-
24679
- --host puts a board that can run shell commands as you on the network, and Frizz has no login: anyone
24680
- who reaches the port controls it. Only do this on a network you trust. An IP address and this
24681
- machine's own hostname work as-is; any other DNS name must be listed with --allowed-host ("*"
24682
- allows any).
24683
25222
 
24684
- --public-origin serves the board through a tunnel or reverse proxy without putting it on the LAN
24685
- at all \u2014 Frizz stays on loopback and the tunnel dials it. It prints a SINGLE-USE access link, and
24686
- shows it as a QR so you can scan it from a phone; press L for a fresh one at any time. Scanning it
24687
- trades the code for a session cookie, so the link itself stops working the moment it is used.`
25223
+ To reach the board from a phone or another machine, press R in the terminal running it: a short
25224
+ walkthrough sets up a frizz.sh name, a Cloudflare Tunnel, Tailscale, or a proxy of your own, and
25225
+ remembers the choice, so a plain launch serves it from then on. The board stays on loopback and
25226
+ shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.`
24688
25227
  );
24689
25228
  process.exit(0);
24690
25229
  }
24691
25230
  if (options.dev || rawArgs.includes("--prod")) fail("--dev and --prod are not available from the registry launcher");
24692
- var cloudConfig = options.cloud ? readCloudConfig() : null;
24693
- if (cloudConfig) options.publicOrigin = `https://${cloudConfig.hostname}`;
24694
- var tunnel = null;
25231
+ var remote = null;
25232
+ var paneHost = null;
24695
25233
  var accessPane = null;
24696
25234
  var activeAccessLink = null;
24697
- var justClaimed = false;
24698
- var bind = (() => {
24699
- try {
24700
- return resolveBindSelection(options, process.env);
24701
- } catch (error) {
24702
- return fail(error);
24703
- }
24704
- })();
24705
25235
  var launchIntent;
24706
25236
  var workspace = (() => {
24707
25237
  try {
@@ -24726,6 +25256,15 @@ var workspace = (() => {
24726
25256
  }
24727
25257
  })();
24728
25258
  process.chdir(workspace.root);
25259
+ if (options.sessions || options.signOut !== void 0) {
25260
+ const running = liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace));
25261
+ if (!running?.port) {
25262
+ console.error("frizz: no board is running for this workspace");
25263
+ process.exit(1);
25264
+ }
25265
+ if (options.signOut !== void 0) await signOutSession(running.port, options.signOut);
25266
+ await listSessions(running.port);
25267
+ }
24729
25268
  if (options.link) {
24730
25269
  const running = liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace));
24731
25270
  if (!running?.port) {
@@ -24770,7 +25309,7 @@ function ownSlug() {
24770
25309
  try {
24771
25310
  return registerProject(
24772
25311
  { dir: workspace.root, id: target.projectId, remoteOwner: resolveProjectLabel(workspace.root)?.split("/")[0] },
24773
- homedir10()
25312
+ homedir11()
24774
25313
  ).entry?.slug;
24775
25314
  } catch {
24776
25315
  return void 0;
@@ -24821,27 +25360,19 @@ async function openOrPrint(port, reused, path = "") {
24821
25360
  } else {
24822
25361
  readout?.settle("browser", "done", url);
24823
25362
  }
24824
- const network = reused ? [] : networkUrls(port, bind.host, void 0, bind.hostname);
24825
- const publicOrigin = reused ? void 0 : bind.publicOrigin;
24826
- const networkFlagsIgnored = reused && (bind.publicOrigin !== void 0 || bindHostIsExposed(bind.host));
24827
- const warnings = [
24828
- ...network.length > 0 ? [EXPOSED_WARNING] : [],
24829
- ...publicOrigin ? [PUBLIC_ORIGIN_WARNING] : [],
24830
- ...networkFlagsIgnored ? [REUSED_NETWORK_FLAGS_WARNING] : []
24831
- ];
25363
+ const publicOrigin = reused ? void 0 : remote?.origin();
25364
+ const warnings = [];
24832
25365
  if (!readout) {
24833
25366
  console.log(`${reused ? "reusing" : "started"} Frizz ${PACKAGE_VERSION} for ${workspace.root}`);
24834
25367
  console.log(url);
24835
- for (const address of network) console.log(address);
24836
25368
  if (publicOrigin) console.log(activeAccessLink?.url ?? `${publicOrigin}/`);
24837
25369
  for (const warning of warnings) console.log(warning);
24838
25370
  return;
24839
25371
  }
24840
- const home = homedir10();
25372
+ const home = homedir11();
24841
25373
  readout.ready(
24842
25374
  [
24843
25375
  { label: "Local", value: boardAddress(url), accent: true },
24844
- ...network.map((address) => ({ label: "Network", value: `${address}/`, accent: true })),
24845
25376
  ...publicOrigin ? [{ label: "Public", value: activeAccessLink?.url ?? `${publicOrigin}/`, accent: true }] : [],
24846
25377
  { label: "Project", value: `${workspace.name} \u2014 ${tildePath(workspace.root, home)}` },
24847
25378
  ...logger.file ? [{ label: "Logs", value: tildePath(logger.file, home) }] : []
@@ -24849,10 +25380,11 @@ async function openOrPrint(port, reused, path = "") {
24849
25380
  reused ? (
24850
25381
  // This launch owns nothing and exits immediately, so ctrl-c would not stop what it reopened.
24851
25382
  "reopened the server already running for this project \xB7 stop it from the terminal that started it"
24852
- ) : options.debug ? void 0 : "press ctrl-c to stop \xB7 run with --debug for the full event feed",
25383
+ ) : options.debug ? void 0 : publicOrigin ? "press L for a fresh sign-in link \xB7 R to change how this board is reached \xB7 ctrl-c to stop" : "press R to reach this board from a phone or another machine \xB7 ctrl-c to stop",
24853
25384
  {
24854
25385
  ...reused ? { status: `already running on port ${port}` } : {},
24855
- ...warnings.length > 0 ? { warning: warnings.join(" ") } : {}
25386
+ ...warnings.length > 0 ? { warning: warnings.join(" ") } : {},
25387
+ ...activeAccessLink && !reused ? { qr: renderQrLines(activeAccessLink.url) } : {}
24856
25388
  }
24857
25389
  );
24858
25390
  }
@@ -24889,21 +25421,17 @@ async function runSupervisor(port, token) {
24889
25421
  void refreshUpdateAvailable();
24890
25422
  const updateAvailablePoll = setInterval(() => void refreshUpdateAvailable(), 30 * 60 * 1e3);
24891
25423
  updateAvailablePoll.unref();
24892
- if (options.cloud && !cloudConfig) {
24893
- cloudConfig = await establishCloudConfig(await promptForCloudName(), port);
24894
- writeCloudConfig(cloudConfig);
24895
- bind.publicOrigin = `https://${cloudConfig.hostname}`;
24896
- justClaimed = true;
24897
- }
24898
25424
  const supervisor = await startDevSupervisor({
24899
25425
  port,
24900
- host: bind.host,
24901
- allowedHosts: bind.allowedHosts,
24902
- ...bind.publicOrigin ? { publicOrigin: bind.publicOrigin } : {},
25426
+ host: LOOPBACK_BIND_HOST,
25427
+ allowedHosts: [],
24903
25428
  // A spent code repaints the open QR pane as stale, so nobody photographs a dead link.
24904
25429
  onCodeConsumed: () => accessPane?.markConsumed(),
24905
25430
  // Persisted beside the project's other state, so a restart does not sign every device out.
24906
- ...bind.publicOrigin ? { sessionKey: loadOrCreateSessionKey(workspace.stateDir) } : {},
25431
+ // Always, not only when public: the origin can be switched on later (press R), and a phone signed
25432
+ // in through one name stays signed in through the next.
25433
+ sessionKey: loadOrCreateSessionKey(workspace.stateDir),
25434
+ sessionDirectory: fileSessionDirectory(workspace.stateDir),
24907
25435
  cwd: workspace.root,
24908
25436
  stateDir: workspace.stateDir,
24909
25437
  launchTarget: target,
@@ -24945,49 +25473,41 @@ async function runSupervisor(port, token) {
24945
25473
  process.exit(0);
24946
25474
  }
24947
25475
  });
24948
- activeAccessLink = bind.publicOrigin ? supervisor.issueAccessLink() : null;
24949
- if (cloudConfig) {
24950
- if (isRelayConfig(cloudConfig)) {
24951
- if (!justClaimed) {
24952
- await resolveRunToken(cloudConfig, port, homedir10(), (message) => {
24953
- logger.warn("relay", message);
24954
- console.error(`frizz: ${message}`);
24955
- });
24956
- }
24957
- tunnel = await startRelay(cloudConfig, port, homedir10(), (message) => logger.info("relay", message));
24958
- logger.info("relay", `serving ${cloudConfig.hostname} through the Frizz relay`);
24959
- } else {
24960
- const runToken = justClaimed ? readTunnelToken() : await resolveRunToken(cloudConfig, port, homedir10(), (message) => {
24961
- logger.warn("tunnel", message);
24962
- console.error(`frizz: ${message}`);
24963
- });
24964
- tunnel = startTunnel(
24965
- cloudConfig,
24966
- (code) => {
24967
- if (code === 0 || code === null) return;
24968
- logger.error("tunnel", `cloudflared exited with code ${code}; the public hostname is now unreachable`);
24969
- },
24970
- (message) => {
24971
- logger.error("tunnel", message);
24972
- console.error(`frizz: ${message}`);
24973
- },
24974
- homedir10(),
24975
- runToken ?? void 0
24976
- );
24977
- logger.info("tunnel", `running cloudflared for ${cloudConfig.hostname}`);
24978
- }
25476
+ remote = createRemoteController({ host: supervisor, port, log: logger, say: (message) => console.error(`frizz: ${message}`) });
25477
+ try {
25478
+ await remote.serveSaved();
25479
+ } catch (error) {
25480
+ const message = error instanceof Error ? error.message : String(error);
25481
+ logger.error("remote", message);
25482
+ console.error(`frizz: the saved remote setup could not start: ${message}`);
24979
25483
  }
24980
- if (bind.publicOrigin) accessPane = installAccessPane({ issue: () => supervisor.issueAccessLink() });
25484
+ activeAccessLink = remote.origin() ? supervisor.issueAccessLink() : null;
25485
+ accessPane = createAccessPane({ issue: () => supervisor.issueAccessLink() });
25486
+ const remotePane = createRemotePane({
25487
+ port,
25488
+ current: () => remote?.current() ?? null,
25489
+ apply: (next, applyOptions) => remote.apply(next, applyOptions),
25490
+ claim: (name) => establishCloudConfig(name, port),
25491
+ issueLink: () => supervisor.issueAccessLink(),
25492
+ probes: { github: probeGithub, cloudflared: probeCloudflared, tailscale: probeTailscale },
25493
+ onChanged: (config) => logger.info("remote", config ? `reached at https://${config.hostname}` : "loopback only")
25494
+ });
25495
+ paneHost = installPaneHost({ bindings: { l: accessPane, L: accessPane, r: remotePane, R: remotePane } });
24981
25496
  const stop = createSupervisorShutdownHandler({
24982
25497
  close: () => supervisor.close(),
24983
25498
  force: () => supervisor.forceStop(),
24984
25499
  release: () => owner.release(),
25500
+ // Acknowledge the first signal on the spot; the drain that follows is bounded but not instant.
25501
+ onStop: () => {
25502
+ logger.info("launcher", "stop signal received; draining the control plane");
25503
+ readout?.notice("progress", "Stopping", "draining the control plane \u2014 press ctrl-c again to force");
25504
+ },
24985
25505
  exit: (code) => {
24986
25506
  logger.info("launcher", `stopped with code ${code}`);
24987
- accessPane?.dispose();
24988
- tunnel?.stop();
25507
+ paneHost?.dispose();
25508
+ remote?.stop();
24989
25509
  const suffix = logger.file ? `
24990
- log: ${tildePath(logger.file, homedir10())}` : "";
25510
+ log: ${tildePath(logger.file, homedir11())}` : "";
24991
25511
  process.stdout.write(
24992
25512
  code === 0 ? `
24993
25513
  Frizz stopped. Running agents keep going \u2014 they are detached daemons.${suffix}
@@ -25031,7 +25551,7 @@ try {
25031
25551
  let release = await acquireGlobalLaunchLock();
25032
25552
  let portReservation;
25033
25553
  try {
25034
- const allocation = await allocatePort(options.port, readPreferredPort(workspace.stateDir), { host: bind.host });
25554
+ const allocation = await allocatePort(options.port, readPreferredPort(workspace.stateDir), { host: LOOPBACK_BIND_HOST });
25035
25555
  const port = allocation.port;
25036
25556
  portReservation = allocation.release;
25037
25557
  const running = runSupervisor(port, claim.lease.token);