frizz 0.13.7 → 0.13.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 (3) hide show
  1. package/README.md +7 -7
  2. package/dist/frizz.js +1770 -1457
  3. package/package.json +2 -2
package/dist/frizz.js CHANGED
@@ -180,6 +180,324 @@ var init_local_origin = __esm({
180
180
  }
181
181
  });
182
182
 
183
+ // packages/server/src/access-codes.ts
184
+ import { randomBytes, timingSafeEqual, createHmac } from "node:crypto";
185
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
186
+ import { dirname, join as join2 } from "node:path";
187
+ function memorySessionDirectory(seed = []) {
188
+ const records = new Map(seed.map((r) => [r.id, r]));
189
+ return {
190
+ record: (r) => void records.set(r.id, r),
191
+ isRevoked: (id) => records.get(id)?.revokedAt !== void 0,
192
+ // Reverse FIRST, then sort. Array.sort is stable, so two devices that redeemed in the same
193
+ // millisecond — which two taps on one page reliably are — keep newest-inserted-first instead of
194
+ // falling back to insertion order and reading as oldest first.
195
+ list: () => [...records.values()].reverse().sort((a, b) => b.createdAt - a.createdAt),
196
+ revoke(id) {
197
+ const found = records.get(id);
198
+ if (!found || found.revokedAt !== void 0) return false;
199
+ found.revokedAt = Date.now();
200
+ return true;
201
+ },
202
+ revokeAll() {
203
+ let n = 0;
204
+ const now = Date.now();
205
+ for (const r of records.values()) {
206
+ if (r.revokedAt !== void 0) continue;
207
+ r.revokedAt = now;
208
+ if (r.expiresAt === void 0 || r.expiresAt > now) n++;
209
+ }
210
+ return n;
211
+ }
212
+ };
213
+ }
214
+ function describeDevice(userAgent) {
215
+ const ua = userAgent ?? "";
216
+ if (!ua.trim()) return "unknown device";
217
+ 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";
218
+ 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" : "";
219
+ return browser ? `${browser} on ${os}` : os;
220
+ }
221
+ function defaultRandomToken(bytes) {
222
+ return randomBytes(bytes).toString("base64url");
223
+ }
224
+ function secretsMatch(a, b) {
225
+ const left = Buffer.from(a);
226
+ const right = Buffer.from(b);
227
+ return left.length === right.length && timingSafeEqual(left, right);
228
+ }
229
+ function fileSessionDirectory(stateDir, now = Date.now) {
230
+ const path = join2(stateDir, "sessions.json");
231
+ let records = [];
232
+ try {
233
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
234
+ if (Array.isArray(parsed)) {
235
+ records = parsed.filter(
236
+ (r) => !!r && typeof r.id === "string" && typeof r.createdAt === "number"
237
+ );
238
+ }
239
+ } catch {
240
+ }
241
+ const inner = memorySessionDirectory(records);
242
+ const flush = () => {
243
+ try {
244
+ mkdirSync(dirname(path), { recursive: true });
245
+ writeFileSync(path, JSON.stringify(inner.list()), { mode: 384 });
246
+ } catch (error) {
247
+ if (process.env.FRIZZ_DEBUG_SESSIONS) console.error("[sessions] flush failed:", error);
248
+ }
249
+ };
250
+ return {
251
+ record(r) {
252
+ inner.record(r);
253
+ flush();
254
+ },
255
+ isRevoked: (id) => inner.isRevoked(id),
256
+ list: () => inner.list(),
257
+ revoke(id) {
258
+ const ok = inner.revoke(id);
259
+ if (ok) flush();
260
+ return ok;
261
+ },
262
+ revokeAll() {
263
+ const n = inner.revokeAll();
264
+ if (n > 0) flush();
265
+ return n;
266
+ }
267
+ };
268
+ }
269
+ function writeSessionKey(path, key) {
270
+ mkdirSync(dirname(path), { recursive: true });
271
+ writeFileSync(path, key, { mode: 384 });
272
+ try {
273
+ chmodSync(path, 384);
274
+ } catch {
275
+ }
276
+ }
277
+ function loadOrCreateSessionKey(stateDir) {
278
+ const path = join2(stateDir, SESSION_KEY_FILE);
279
+ try {
280
+ const existing = readFileSync(path);
281
+ if (existing.byteLength >= 32) return existing;
282
+ } catch {
283
+ }
284
+ return rotateSessionKey(stateDir);
285
+ }
286
+ function rotateSessionKey(stateDir) {
287
+ const key = randomBytes(32);
288
+ writeSessionKey(join2(stateDir, SESSION_KEY_FILE), key);
289
+ return key;
290
+ }
291
+ function readSessionEpoch(stateDir) {
292
+ try {
293
+ const parsed = JSON.parse(readFileSync(join2(stateDir, SESSION_EPOCH_FILE), "utf8"));
294
+ return Number.isInteger(parsed?.epoch) && parsed.epoch >= 0 ? parsed.epoch : 0;
295
+ } catch {
296
+ return 0;
297
+ }
298
+ }
299
+ function advanceSessionEpoch(stateDir, epoch = SESSION_EPOCH) {
300
+ const from = readSessionEpoch(stateDir);
301
+ if (from >= epoch) return { advanced: false, epoch: from };
302
+ const keyPath = join2(stateDir, SESSION_KEY_FILE);
303
+ const rotatedKey = existsSync(keyPath);
304
+ if (rotatedKey) rotateSessionKey(stateDir);
305
+ mkdirSync(stateDir, { recursive: true });
306
+ writeFileSync(
307
+ join2(stateDir, SESSION_EPOCH_FILE),
308
+ JSON.stringify({ epoch, advancedAt: (/* @__PURE__ */ new Date()).toISOString() }),
309
+ { mode: 384 }
310
+ );
311
+ return { advanced: true, from, to: epoch, rotatedKey };
312
+ }
313
+ function sessionEpochNotice(count) {
314
+ if (count <= 0) return null;
315
+ const devices = count === 1 ? "1 remote device" : `${count} remote devices`;
316
+ return `Security update: signed out ${devices} (a phone or browser reaching this board through frizz.sh or a tunnel). Each must sign in again with a fresh link.`;
317
+ }
318
+ function sessionEpochLogLine(advance, signedOut) {
319
+ if (!advance.advanced || !advance.rotatedKey) return null;
320
+ const count = signedOut === null ? "an unknown number of" : String(signedOut);
321
+ return `session epoch ${advance.from} -> ${advance.to}: rotated the session key and signed out ${count} recorded remote device(s); every older remote session is refused`;
322
+ }
323
+ function loadSessionState(stateDir, epoch = SESSION_EPOCH) {
324
+ const advance = advanceSessionEpoch(stateDir, epoch);
325
+ const key = loadOrCreateSessionKey(stateDir);
326
+ const directory = fileSessionDirectory(stateDir);
327
+ const signedOut = advance.advanced ? directory.revokeAll() : 0;
328
+ return { key, directory, rotateKey: () => rotateSessionKey(stateDir), advance, signedOut };
329
+ }
330
+ var CODE_BYTES, SESSION_BYTES, SESSION_ID_BYTES, DEFAULT_CODE_TTL_MS, DEFAULT_SESSION_TTL_MS, SESSION_EPOCH, AccessStore, SESSION_KEY_FILE, SESSION_EPOCH_FILE;
331
+ var init_access_codes = __esm({
332
+ "packages/server/src/access-codes.ts"() {
333
+ "use strict";
334
+ CODE_BYTES = 16;
335
+ SESSION_BYTES = 32;
336
+ SESSION_ID_BYTES = 6;
337
+ DEFAULT_CODE_TTL_MS = 5 * 6e4;
338
+ DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 6e4;
339
+ SESSION_EPOCH = 1;
340
+ AccessStore = class _AccessStore {
341
+ codes = /* @__PURE__ */ new Map();
342
+ options;
343
+ sessions;
344
+ /** Signing key for sessions. Persisted by the caller; a fresh one signs every device out. */
345
+ signingKey;
346
+ rotateSigningKey;
347
+ constructor(options2 = {}) {
348
+ this.signingKey = options2.signingKey ?? randomBytes(32);
349
+ this.rotateSigningKey = options2.rotateSigningKey ?? (() => randomBytes(32));
350
+ this.sessions = options2.sessions ?? memorySessionDirectory();
351
+ this.options = {
352
+ codeTtlMs: options2.codeTtlMs ?? DEFAULT_CODE_TTL_MS,
353
+ sessionTtlMs: options2.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,
354
+ now: options2.now ?? Date.now,
355
+ randomToken: options2.randomToken ?? defaultRandomToken,
356
+ onConsumed: options2.onConsumed
357
+ };
358
+ }
359
+ /** Mint a code to show as a QR or a link. Minting does not invalidate codes already outstanding. */
360
+ issue() {
361
+ this.sweep();
362
+ const now = this.options.now();
363
+ const code = {
364
+ code: this.options.randomToken(CODE_BYTES),
365
+ createdAt: now,
366
+ expiresAt: now + this.options.codeTtlMs
367
+ };
368
+ this.codes.set(code.code, code);
369
+ return { code: code.code, createdAt: code.createdAt, expiresAt: code.expiresAt };
370
+ }
371
+ /**
372
+ * Trade a code for a session, at most once, ever.
373
+ *
374
+ * The consumed marker is set BEFORE anything else can observe success, and JS runs this method to
375
+ * completion without interleaving, so two browsers racing the same code cannot both win. That
376
+ * atomicity is what makes "single-use" true rather than aspirational — it is the first thing the
377
+ * tests attack.
378
+ */
379
+ redeem(code, device) {
380
+ if (!code) return { ok: false, reason: "unknown" };
381
+ const stored = this.codes.get(code);
382
+ if (!stored || !secretsMatch(stored.code, code)) return { ok: false, reason: "unknown" };
383
+ const now = this.options.now();
384
+ if (stored.consumedAt !== void 0) return { ok: false, reason: "already-used" };
385
+ if (now >= stored.expiresAt) {
386
+ this.sweep();
387
+ return { ok: false, reason: "expired" };
388
+ }
389
+ stored.consumedAt = now;
390
+ this.sweep();
391
+ this.options.onConsumed?.(code);
392
+ const minted = this.mintSession(now);
393
+ const expiresAt = now + this.options.sessionTtlMs;
394
+ this.sessions.record({ id: minted.id, label: device ?? "unknown device", createdAt: now, expiresAt });
395
+ return { ok: true, session: minted.session, expiresAt, id: minted.id };
396
+ }
397
+ /**
398
+ * Sign out EVERY device: `frizz --sign-out all`, and what a session-epoch bump asks of a running board.
399
+ *
400
+ * The denylist alone is not enough for that. It names only the ids it recorded, so a session minted
401
+ * before ids existed (2026-08-25), or while the directory was held in memory, would survive it. A new
402
+ * key kills every outstanding session whatever it carries; the denylist entries are what make
403
+ * `frizz --sessions` stop listing those devices as signed in. Returns how many recorded devices that was.
404
+ */
405
+ signOutAll() {
406
+ this.signingKey = this.rotateSigningKey();
407
+ return this.sessions.revokeAll();
408
+ }
409
+ /**
410
+ * A session is `<expiry>.<id>.<nonce>.<hmac>` — signed with a key the caller persists so it survives
411
+ * a restart, and self-describing enough that a stale cookie is rejected without any lookup.
412
+ *
413
+ * The `id` is what makes ONE device signable-out. It is short because an operator types it into
414
+ * `frizz --sign-out <id>`, and it is inside the signed payload so it cannot be swapped for another
415
+ * device's. Rotating the key still revokes everything at once; this is the scalpel beside that axe.
416
+ *
417
+ * A session minted before ids existed has a two-part payload and still verifies — it simply has no id
418
+ * to revoke individually. Adding ids was not meant to sign every device out; when that IS the intent,
419
+ * SESSION_EPOCH does it on purpose, by rotating the key.
420
+ */
421
+ mintSession(now) {
422
+ const expiresAt = now + this.options.sessionTtlMs;
423
+ const id = this.options.randomToken(SESSION_ID_BYTES);
424
+ const nonce = this.options.randomToken(SESSION_BYTES);
425
+ const payload = `${expiresAt}.${id}.${nonce}`;
426
+ return { session: `${payload}.${this.sign(payload)}`, id };
427
+ }
428
+ /** The expiry a session carries in its own payload. Only called on a session that already verified. */
429
+ static expiryOf(session) {
430
+ return Number(session.slice(0, session.indexOf(".")));
431
+ }
432
+ /** The id inside a session payload, or null for a legacy two-part one. */
433
+ static idOf(payload) {
434
+ const parts = payload.split(".");
435
+ return parts.length >= 3 ? parts[1] ?? null : null;
436
+ }
437
+ sign(payload) {
438
+ return createHmac("sha256", this.signingKey).update(payload).digest("base64url");
439
+ }
440
+ /** Is this cookie a session signed by this board's key, and still in date? */
441
+ verifySession(session) {
442
+ if (!session) return false;
443
+ const cut = session.lastIndexOf(".");
444
+ if (cut <= 0) return false;
445
+ const payload = session.slice(0, cut);
446
+ const signature = session.slice(cut + 1);
447
+ if (!secretsMatch(signature, this.sign(payload))) return false;
448
+ const expiresAt = Number(payload.slice(0, payload.indexOf(".")));
449
+ if (!Number.isFinite(expiresAt) || this.options.now() >= expiresAt) return false;
450
+ const id = _AccessStore.idOf(payload);
451
+ return !(id && this.sessions.isRevoked(id));
452
+ }
453
+ /**
454
+ * Sign out the device HOLDING this session — the "Sign out this device" row, not `--sign-out`.
455
+ *
456
+ * The id comes from the session itself, and only after its signature verifies, so the one thing this
457
+ * can ever revoke is the credential the caller just proved it holds. There is deliberately no
458
+ * parameter naming any other id: signing out SOMEONE ELSE stays loopback-only, because a stolen
459
+ * session must not be able to evict the owner and keep the board.
460
+ *
461
+ * - `revoked`: the id is on the denylist now, so a surviving copy of this cookie is refused too.
462
+ * - `legacy`: a session minted before ids existed verifies but has no id to revoke on its own. The
463
+ * caller can still clear the cookie; signOutAll(), which rotates the key, is the way to kill a copy.
464
+ * - `no-session`: nothing valid was presented — absent, forged, expired, or already signed out.
465
+ */
466
+ signOutSession(session) {
467
+ if (!session || !this.verifySession(session)) return { result: "no-session" };
468
+ const id = _AccessStore.idOf(session.slice(0, session.lastIndexOf(".")));
469
+ if (!id) return { result: "legacy" };
470
+ if (!this.sessions.revoke(id)) {
471
+ this.sessions.record({ id, label: "unknown device", createdAt: this.options.now(), expiresAt: _AccessStore.expiryOf(session) });
472
+ this.sessions.revoke(id);
473
+ }
474
+ return this.sessions.isRevoked(id) ? { result: "revoked", id } : { result: "no-session" };
475
+ }
476
+ /**
477
+ * Drop codes past their expiry so a long-lived board does not accumulate them forever.
478
+ *
479
+ * A CONSUMED code is deliberately kept until that same expiry rather than deleted on use. Deleting it
480
+ * immediately would make a replay indistinguishable from a typo — both "unknown" — and the difference
481
+ * matters: "already-used" is the message that tells someone their link was used by somebody else.
482
+ * It costs one small entry for at most the code TTL.
483
+ */
484
+ sweep() {
485
+ const now = this.options.now();
486
+ for (const [key, stored] of this.codes) {
487
+ if (now >= stored.expiresAt) this.codes.delete(key);
488
+ }
489
+ }
490
+ /** Outstanding codes that could still be redeemed — neither consumed nor expired. */
491
+ outstanding() {
492
+ this.sweep();
493
+ return [...this.codes.values()].filter((stored) => stored.consumedAt === void 0).map(({ code, createdAt, expiresAt }) => ({ code, createdAt, expiresAt }));
494
+ }
495
+ };
496
+ SESSION_KEY_FILE = "session-key";
497
+ SESSION_EPOCH_FILE = "session-epoch";
498
+ }
499
+ });
500
+
183
501
  // packages/server/src/local-path.ts
184
502
  function normalizeLocalPath(path, platform = process.platform) {
185
503
  return platform === "win32" ? path.replace(/^\/([a-zA-Z]:[\\/])/, "$1") : path;
@@ -191,12 +509,12 @@ var init_local_path = __esm({
191
509
  });
192
510
 
193
511
  // packages/server/src/local-image.ts
194
- import { readFileSync as readFileSync2, realpathSync, statSync } from "node:fs";
512
+ import { createReadStream, realpathSync, statSync } from "node:fs";
195
513
  import { extname, isAbsolute } from "node:path";
196
- function resolveLocalImage(rawPath) {
514
+ function resolveLocalImage(rawPath, range) {
197
515
  if (rawPath) rawPath = normalizeLocalPath(rawPath);
198
516
  if (!rawPath || !isAbsolute(rawPath)) return { status: 400 };
199
- const contentType = IMAGE_CONTENT_TYPE[extname(rawPath).toLowerCase()];
517
+ const contentType = CONTENT_TYPE[extname(rawPath).toLowerCase()];
200
518
  if (!contentType) return { status: 400 };
201
519
  let real;
202
520
  try {
@@ -204,14 +522,48 @@ function resolveLocalImage(rawPath) {
204
522
  } catch {
205
523
  return { status: 404 };
206
524
  }
525
+ let size;
207
526
  try {
208
- if (!statSync(real).isFile()) return { status: 404 };
209
- return { status: 200, contentType, body: readFileSync2(real) };
527
+ const stat = statSync(real);
528
+ if (!stat.isFile()) return { status: 404 };
529
+ size = stat.size;
210
530
  } catch {
211
531
  return { status: 404 };
212
532
  }
533
+ const span = range ? byteRange(range, size) : null;
534
+ if (span === "unsatisfiable") return { status: 416, size };
535
+ if (span) return { status: 206, contentType, file: real, size, start: span.start, end: span.end };
536
+ return { status: 200, contentType, file: real, size, start: 0, end: size - 1 };
537
+ }
538
+ function byteRange(header, size) {
539
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
540
+ if (!match) return null;
541
+ const [, first, last] = match;
542
+ if (first === "" && last === "") return null;
543
+ if (first === "") {
544
+ const suffix = Number(last);
545
+ if (suffix === 0 || size === 0) return "unsatisfiable";
546
+ return { start: Math.max(size - suffix, 0), end: size - 1 };
547
+ }
548
+ const start = Number(first);
549
+ if (last !== "" && Number(last) < start) return null;
550
+ if (start >= size) return "unsatisfiable";
551
+ return { start, end: last === "" ? size - 1 : Math.min(Number(last), size - 1) };
552
+ }
553
+ function localImageHeaders(result) {
554
+ return {
555
+ "content-type": result.contentType,
556
+ "content-length": String(result.end - result.start + 1),
557
+ "accept-ranges": "bytes",
558
+ "cache-control": "private, max-age=60",
559
+ ...result.status === 206 ? { "content-range": `bytes ${result.start}-${result.end}/${result.size}` } : {}
560
+ };
561
+ }
562
+ function localImageStream(result) {
563
+ if (result.end < result.start) return null;
564
+ return createReadStream(result.file, { start: result.start, end: result.end });
213
565
  }
214
- var IMAGE_CONTENT_TYPE;
566
+ var IMAGE_CONTENT_TYPE, VIDEO_CONTENT_TYPE, CONTENT_TYPE;
215
567
  var init_local_image = __esm({
216
568
  "packages/server/src/local-image.ts"() {
217
569
  "use strict";
@@ -223,65 +575,333 @@ var init_local_image = __esm({
223
575
  ".gif": "image/gif",
224
576
  ".webp": "image/webp"
225
577
  };
578
+ VIDEO_CONTENT_TYPE = {
579
+ ".mp4": "video/mp4",
580
+ ".m4v": "video/mp4",
581
+ ".webm": "video/webm",
582
+ ".mov": "video/quicktime"
583
+ };
584
+ CONTENT_TYPE = { ...IMAGE_CONTENT_TYPE, ...VIDEO_CONTENT_TYPE };
226
585
  }
227
586
  });
228
587
 
229
- // node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js
230
- var require_identity = __commonJS({
231
- "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js"(exports) {
588
+ // packages/server/src/supervisor-pages.ts
589
+ function escapeHtml(value) {
590
+ return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
591
+ }
592
+ function retryTarget(url) {
593
+ return url.startsWith("/") && !url.startsWith("//") ? url : "/";
594
+ }
595
+ function documentShell(options2) {
596
+ return `<!doctype html><html lang="en" data-font="sans"><head><meta charset="utf-8"><title>${options2.title}</title><meta name="viewport" content="width=device-width,initial-scale=1">` + (options2.appearance ? `<meta name="color-scheme" content="dark light"><meta name="theme-color" content="#0d0e10">` : `<meta name="color-scheme" content="dark light"><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0d0e10"><meta name="theme-color" media="(prefers-color-scheme: light)" content="#f7f7f7">`) + `<style>${options2.style ? `${BASE_STYLE}
597
+ ${options2.style}` : BASE_STYLE}</style>` + (options2.appearance ? `<script>${THEME_SCRIPT}</script>` : "") + `</head><body${options2.bodyAttributes ? ` ${options2.bodyAttributes}` : ""}><main>${options2.body}</main>` + (options2.script ? `<script>${options2.script}</script>` : "") + `</body></html>`;
598
+ }
599
+ function recoveryPage(url, variant = "starting") {
600
+ const copy = RECOVERY_COPY[variant];
601
+ const target2 = escapeHtml(retryTarget(url));
602
+ return documentShell({
603
+ title: copy.title,
604
+ style: RECOVERY_STYLE,
605
+ appearance: true,
606
+ bodyAttributes: `data-target="${target2}"`,
607
+ body: `<h1><span class="dot" id="dot" aria-hidden="true"></span><span id="heading">${copy.heading}</span></h1><p class="detail" id="detail">${copy.detail}</p><pre class="log" id="log" hidden></pre><p class="note">${THREADS_NOTE}</p><div class="actions"><a class="btn" href="${target2}">Try again</a><button class="btn" type="button" id="restart">Restart Frizz</button></div><p class="probe" id="probe" aria-live="polite"></p>`,
608
+ script: RECOVERY_SCRIPT
609
+ });
610
+ }
611
+ function unauthorizedPage(reason) {
612
+ const detail = reason === "already-used" ? "That link has already been used. Generate a fresh one." : reason === "expired" ? "That link expired. Generate a fresh one." : "This address needs a current access link.";
613
+ return documentShell({
614
+ title: "Unauthorized",
615
+ body: `<h1>Unauthorized</h1><p class="detail">${detail}</p>`
616
+ });
617
+ }
618
+ function unlistedHostPage(name) {
619
+ const shown = escapeHtml(name);
620
+ return documentShell({
621
+ title: "Not served by this name",
622
+ appearance: true,
623
+ body: `<h1>Not served by this name</h1><p class="detail">Frizz does not answer to <code>${shown}</code>. A name a browser has to resolve is how DNS rebinding makes another site's page count as this board, so an exposed board accepts only its own hostname and the names its operator lists.</p><p class="note">Open the board by its IP address, or relaunch with <code>--allowed-host ${shown}</code> (or <code>FRIZZ_ALLOWED_HOSTS=${shown}</code>) to accept this one.</p>`
624
+ });
625
+ }
626
+ var BASE_STYLE, RECOVERY_STYLE, THEME_SCRIPT, RECOVERY_SCRIPT, RECOVERY_COPY, THREADS_NOTE;
627
+ var init_supervisor_pages = __esm({
628
+ "packages/server/src/supervisor-pages.ts"() {
232
629
  "use strict";
233
- var ALIAS = Symbol.for("yaml.alias");
234
- var DOC = Symbol.for("yaml.document");
235
- var MAP = Symbol.for("yaml.map");
236
- var PAIR = Symbol.for("yaml.pair");
237
- var SCALAR = Symbol.for("yaml.scalar");
238
- var SEQ = Symbol.for("yaml.seq");
239
- var NODE_TYPE = Symbol.for("yaml.node.type");
240
- var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
241
- var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
242
- var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
243
- var isPair = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === PAIR;
244
- var isScalar = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SCALAR;
245
- var isSeq = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SEQ;
246
- function isCollection(node) {
247
- if (node && typeof node === "object")
248
- switch (node[NODE_TYPE]) {
249
- case MAP:
250
- case SEQ:
251
- return true;
252
- }
253
- return false;
254
- }
255
- function isNode(node) {
256
- if (node && typeof node === "object")
257
- switch (node[NODE_TYPE]) {
258
- case ALIAS:
259
- case MAP:
260
- case SCALAR:
261
- case SEQ:
262
- return true;
263
- }
264
- return false;
265
- }
266
- var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
267
- exports.ALIAS = ALIAS;
268
- exports.DOC = DOC;
269
- exports.MAP = MAP;
270
- exports.NODE_TYPE = NODE_TYPE;
271
- exports.PAIR = PAIR;
272
- exports.SCALAR = SCALAR;
273
- exports.SEQ = SEQ;
274
- exports.hasAnchor = hasAnchor;
275
- exports.isAlias = isAlias;
276
- exports.isCollection = isCollection;
277
- exports.isDocument = isDocument;
278
- exports.isMap = isMap;
279
- exports.isNode = isNode;
280
- exports.isPair = isPair;
281
- exports.isScalar = isScalar;
282
- exports.isSeq = isSeq;
630
+ BASE_STYLE = `
631
+ *,*::before,*::after{box-sizing:border-box}
632
+ :root{
633
+ color-scheme:dark;
634
+ --bg:#0d0e10;--panel:#131519;--panel-2:#181b20;
635
+ --border:#26282d;--border-strong:#33363c;--control-border:#26282d;--control-strong:#33363c;
636
+ --fg:#e6e7e9;--muted:#8b8f96;--accent:#e8b923;--danger:#fca5a5;
637
+ --focus-border:color-mix(in srgb,var(--accent) 55%,transparent);--focus-ring:color-mix(in srgb,var(--accent) 22%,transparent);
638
+ --sans:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
639
+ --mono:ui-monospace,"JetBrains Mono","SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
640
+ --ease:cubic-bezier(0,0,.2,1);
641
+ }
642
+ :root[data-theme=light]{
643
+ color-scheme:light;
644
+ --bg:#f7f7f7;--panel:#fff;--panel-2:#fafafa;
645
+ --border:#dedede;--border-strong:#cecece;--control-border:#858585;--control-strong:#7d7d7d;
646
+ --fg:#292929;--muted:#606060;--accent:#416896;--danger:#cf222e;
647
+ --focus-border:#416896;--focus-ring:#416896;
648
+ }
649
+ @media(prefers-color-scheme:light){
650
+ :root:not([data-theme]){
651
+ color-scheme:light;
652
+ --bg:#f7f7f7;--panel:#fff;--panel-2:#fafafa;
653
+ --border:#dedede;--border-strong:#cecece;--control-border:#858585;--control-strong:#7d7d7d;
654
+ --fg:#292929;--muted:#606060;--accent:#416896;--danger:#cf222e;
655
+ --focus-border:#416896;--focus-ring:#416896;
283
656
  }
284
- });
657
+ }
658
+ html,body{background:var(--bg)}
659
+ body{
660
+ margin:0;min-height:100vh;min-height:100dvh;
661
+ display:flex;align-items:center;justify-content:center;padding:24px;
662
+ color:var(--fg);font-family:var(--sans);font-size:12.5px;line-height:1.55;
663
+ -webkit-font-smoothing:antialiased;
664
+ }
665
+ main{width:100%;max-width:28rem;padding:18px;border:1px solid var(--border-strong);border-radius:12px;background:var(--panel-2)}
666
+ h1{margin:0;display:flex;align-items:baseline;gap:7px;font-size:14px;font-weight:600;letter-spacing:-.01em;line-height:1.4}
667
+ p{margin:0;text-wrap:pretty}
668
+ .detail{margin-top:7px;color:color-mix(in srgb,var(--fg) 88%,transparent)}
669
+ .note{margin-top:12px;padding-top:11px;border-top:1px solid var(--border);color:var(--muted);font-size:11.5px;line-height:1.6}
670
+ code{font-family:var(--mono);font-size:.92em;padding:.1em .35em;border-radius:4px;background:var(--panel);border:1px solid var(--border);white-space:nowrap}
671
+ [hidden]{display:none!important}
672
+ `.trim();
673
+ RECOVERY_STYLE = `
674
+ .actions{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px}
675
+ /* ErrorBoundary's ACTION_CLASS, one size up for a page that is alone in a viewport. */
676
+ .btn{
677
+ appearance:none;display:inline-flex;align-items:center;
678
+ border:1px solid var(--control-border);border-radius:6px;background:transparent;
679
+ padding:5px 10px;font:inherit;font-size:11.5px;color:color-mix(in srgb,var(--fg) 90%,transparent);
680
+ cursor:pointer;text-decoration:none;
681
+ transition:background-color 120ms var(--ease),border-color 120ms var(--ease),color 120ms var(--ease);
682
+ }
683
+ .btn:hover{background:var(--panel);border-color:var(--control-strong);color:var(--fg)}
684
+ .btn:focus-visible{outline:none;border-color:var(--focus-border);box-shadow:0 0 0 2px var(--focus-ring)}
685
+ .btn:disabled{opacity:.5;pointer-events:none}
686
+ /* min-height holds the slot open while it is LIVE, so a changing status line never nudges the card;
687
+ :empty collapses it when there is nothing live to say, so a halted card has no trailing gutter. */
688
+ .probe{margin-top:12px;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--muted);min-height:1.5em}
689
+ .probe:empty{margin-top:0;min-height:0}
690
+ /* RestartFailureNotice's block, at this altitude: a supervisor \`message\` is raw build output, and it
691
+ reads as what it is \u2014 a terminal excerpt \u2014 only inside a scrolling mono box. */
692
+ .log{
693
+ margin-top:10px;max-height:16rem;overflow:auto;white-space:pre-wrap;overflow-wrap:break-word;
694
+ border:1px solid var(--border);border-radius:6px;background:var(--panel);padding:8px 10px;
695
+ font-family:var(--mono);font-size:11px;line-height:1.6;color:color-mix(in srgb,var(--fg) 70%,transparent);
696
+ }
697
+
698
+ /* THE LIVE DOT, and it is the app's own \`.frizz-live-dot\` \u2014 same 6px, same 28%/14% halo, same 1.25s
699
+ cadence \u2014 because it means the same thing here that it means there: work is in flight.
700
+
701
+ Alignment is an INK problem. A baseline-aligned flex item with no text of its own contributes its
702
+ BORDER-BOX BOTTOM as the baseline, so the dot's ink centre lands 3px above it while the cap band's
703
+ centre sits 0.5cap above it. \`cap\` is the resolved font's own cap height, so the correction holds
704
+ at any size, with nothing to re-measure when the type scale moves. The em spelling is the fallback for a browser without the unit (.35em ~= 0.5 x a
705
+ 0.7em cap) and is overwritten wherever \`cap\` parses. */
706
+ .dot{
707
+ width:6px;height:6px;flex:0 0 auto;border-radius:9999px;background:var(--accent);
708
+ box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 28%,transparent);
709
+ animation:frizz-live-pulse 1.25s ease-in-out infinite;
710
+ transform:translateY(calc(3px - .35em));
711
+ transform:translateY(calc(3px - .5cap));
712
+ }
713
+ .dot[data-state=idle]{animation:none;background:var(--muted);box-shadow:none}
714
+ .dot[data-state=bad]{animation:none;background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 28%,transparent)}
715
+ @keyframes frizz-live-pulse{50%{opacity:.62;box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 14%,transparent)}}
716
+ @media (prefers-reduced-motion:reduce){
717
+ .dot{animation:none;background:transparent;border:2px solid var(--accent);box-shadow:none}
718
+ .dot[data-state=idle]{border-color:var(--muted)}
719
+ .dot[data-state=bad]{border-color:var(--danger);background:transparent}
720
+ }
721
+
722
+ /* The app's \`.shimmer-text\`: a highlight sweeping muted text is how this product says "working", and
723
+ the probe line is the only thing on the page that is. */
724
+ .probe[data-live=yes]{
725
+ color:transparent;
726
+ background:linear-gradient(90deg,var(--muted) 0%,var(--muted) 35%,var(--fg) 50%,var(--muted) 65%,var(--muted) 100%) 0 0/200% 100%;
727
+ -webkit-background-clip:text;background-clip:text;
728
+ animation:frizz-shimmer 2.2s linear infinite;
729
+ }
730
+ @keyframes frizz-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}
731
+ @media (prefers-reduced-motion:reduce){
732
+ .probe[data-live=yes]{animation:none;background:none;color:var(--muted)}
733
+ }
734
+ `.trim();
735
+ THEME_SCRIPT = `var p;try{p=localStorage.getItem("frizz-theme")}catch{}var d=false;try{d=matchMedia&&matchMedia("(prefers-color-scheme: dark)").matches}catch{}var t=p==="dark"||p==="light"?p:d?"dark":"light";document.documentElement.dataset.theme=t;document.documentElement.style.colorScheme=t;document.querySelector('meta[name="theme-color"]').content=t==="dark"?"#0d0e10":"#f7f7f7"`;
736
+ RECOVERY_SCRIPT = `
737
+ (function(){
738
+ var target=document.body.dataset.target||"/"
739
+ var heading=document.getElementById("heading")
740
+ var detail=document.getElementById("detail")
741
+ var probe=document.getElementById("probe")
742
+ var log=document.getElementById("log")
743
+ var dot=document.getElementById("dot")
744
+ var restart=document.getElementById("restart")
745
+ var BUDGET=120000,MIN=600,MAX=4000,GROWTH=1.4
746
+ var delay=MIN,deadline=Date.now()+BUDGET,timer=0,stopped=false,busy=false,told=""
747
+
748
+ function say(text,live){probe.textContent=text;probe.setAttribute("data-live",live?"yes":"no")}
749
+ function halt(text,bad){stopped=true;clearTimeout(timer);dot.setAttribute("data-state",bad?"bad":"idle");say(text,false)}
750
+ function showLog(message){if(message){log.textContent=message;log.hidden=false}else{log.hidden=true}}
751
+ function resume(){stopped=false;busy=false;delay=MIN;deadline=Date.now()+BUDGET;dot.removeAttribute("data-state");clearTimeout(timer);timer=setTimeout(tick,0)}
752
+
753
+ // 503 is this proxy's own "no child". Every other answer \u2014 200, 404, even a 500 from the app \u2014 means
754
+ // something is listening again, and the reader's own URL is the right thing to ask.
755
+ function alive(){
756
+ return fetch(target,{method:"HEAD",cache:"no-store",credentials:"same-origin"})
757
+ .then(function(r){return r.status!==503}).catch(function(){return false})
758
+ }
759
+ function readStatus(){
760
+ return fetch("/_frizz/control/status",{cache:"no-store",headers:{"cache-control":"no-store"}})
761
+ .then(function(r){return r.ok?r.json():null})
762
+ .then(function(b){return b&&b.protocol===1?b:null}).catch(function(){return null})
763
+ }
764
+
765
+ // An unreachable launcher leaves the server-rendered copy alone: it is already right for the case
766
+ // that rendered it, and guessing over it would be worse than saying nothing.
767
+ function narrate(status){
768
+ if(!status||status.state===told)return
769
+ told=status.state
770
+ if(status.state==="failed"){
771
+ heading.textContent="Frizz did not restart"
772
+ // Not a second copy of the note below it \u2014 a lead-in to the log, which is the next thing down.
773
+ detail.textContent="The launcher could not bring the application server back, and reported this:"
774
+ showLog(status.message||"The launcher gave no reason.")
775
+ halt("",true)
776
+ return
777
+ }
778
+ showLog("")
779
+ if(status.state==="restarting"){
780
+ heading.textContent="Frizz is restarting"
781
+ detail.textContent="The application server is coming back up."
782
+ }
783
+ }
784
+
785
+ function tick(){
786
+ if(stopped||busy)return
787
+ // A hidden tab neither polls nor spends its budget; visibilitychange starts the loop again.
788
+ if(document.hidden)return
789
+ alive().then(function(up){
790
+ if(up){say("Frizz is back. Reloading.",true);location.replace(target);return}
791
+ return readStatus().then(function(status){
792
+ narrate(status)
793
+ if(stopped)return
794
+ if(Date.now()>deadline){halt("No answer after 2m. This page stopped checking.",false);return}
795
+ delay=Math.min(MAX,delay*GROWTH)
796
+ say("Waiting for Frizz",true)
797
+ timer=setTimeout(tick,delay)
798
+ })
799
+ })
800
+ }
801
+
802
+ document.addEventListener("visibilitychange",function(){
803
+ if(document.hidden||stopped||busy)return
804
+ resume()
805
+ })
806
+
807
+ restart.addEventListener("click",function(){
808
+ if(busy)return
809
+ busy=true;stopped=false;told="";clearTimeout(timer)
810
+ restart.disabled=true;restart.textContent="Restarting"
811
+ dot.removeAttribute("data-state");showLog("");say("Restarting Frizz",true)
812
+ fetch("/_frizz/control/restart",{method:"POST",headers:{"cache-control":"no-store"}})
813
+ .then(function(r){return r.json().catch(function(){return null}).then(function(b){return{ok:r.ok,status:r.status,body:b}})})
814
+ .catch(function(){return{ok:false,status:0,body:null}})
815
+ .then(function(r){
816
+ restart.disabled=false;restart.textContent="Restart Frizz";busy=false
817
+ if(!r.ok){
818
+ heading.textContent="Frizz did not restart"
819
+ detail.textContent="The launcher could not bring the application server back, and reported this:"
820
+ showLog((r.body&&r.body.message)||("The launcher refused the restart ("+(r.status||"no response")+")."))
821
+ halt("",true)
822
+ return
823
+ }
824
+ // Accepted. The probe, not this response, decides whether the child is actually back.
825
+ resume()
826
+ })
827
+ })
828
+
829
+ say("Waiting for Frizz",true)
830
+ tick()
831
+ })()
832
+ `.trim();
833
+ RECOVERY_COPY = {
834
+ starting: {
835
+ title: "Restarting | Frizz",
836
+ heading: "Frizz is restarting",
837
+ detail: "The application server is not listening yet. This page reloads as soon as it answers."
838
+ },
839
+ unreachable: {
840
+ title: "Not responding | Frizz",
841
+ heading: "Frizz is not responding",
842
+ detail: "The application server accepted this request and then dropped it. Restarting it usually clears this."
843
+ }
844
+ };
845
+ THREADS_NOTE = "Threads already running are not affected. Each runs as its own detached process, and Frizz reattaches to it on restart.";
846
+ }
847
+ });
848
+
849
+ // node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js
850
+ var require_identity = __commonJS({
851
+ "node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js"(exports) {
852
+ "use strict";
853
+ var ALIAS = Symbol.for("yaml.alias");
854
+ var DOC = Symbol.for("yaml.document");
855
+ var MAP = Symbol.for("yaml.map");
856
+ var PAIR = Symbol.for("yaml.pair");
857
+ var SCALAR = Symbol.for("yaml.scalar");
858
+ var SEQ = Symbol.for("yaml.seq");
859
+ var NODE_TYPE = Symbol.for("yaml.node.type");
860
+ var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
861
+ var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
862
+ var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
863
+ var isPair = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === PAIR;
864
+ var isScalar = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SCALAR;
865
+ var isSeq = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SEQ;
866
+ function isCollection(node) {
867
+ if (node && typeof node === "object")
868
+ switch (node[NODE_TYPE]) {
869
+ case MAP:
870
+ case SEQ:
871
+ return true;
872
+ }
873
+ return false;
874
+ }
875
+ function isNode(node) {
876
+ if (node && typeof node === "object")
877
+ switch (node[NODE_TYPE]) {
878
+ case ALIAS:
879
+ case MAP:
880
+ case SCALAR:
881
+ case SEQ:
882
+ return true;
883
+ }
884
+ return false;
885
+ }
886
+ var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
887
+ exports.ALIAS = ALIAS;
888
+ exports.DOC = DOC;
889
+ exports.MAP = MAP;
890
+ exports.NODE_TYPE = NODE_TYPE;
891
+ exports.PAIR = PAIR;
892
+ exports.SCALAR = SCALAR;
893
+ exports.SEQ = SEQ;
894
+ exports.hasAnchor = hasAnchor;
895
+ exports.isAlias = isAlias;
896
+ exports.isCollection = isCollection;
897
+ exports.isDocument = isDocument;
898
+ exports.isMap = isMap;
899
+ exports.isNode = isNode;
900
+ exports.isPair = isPair;
901
+ exports.isScalar = isScalar;
902
+ exports.isSeq = isSeq;
903
+ }
904
+ });
285
905
 
286
906
  // node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/dist/visit.js
287
907
  var require_visit = __commonJS({
@@ -12362,6 +12982,31 @@ var init_drainable_worker = __esm({
12362
12982
  }
12363
12983
  });
12364
12984
 
12985
+ // packages/shared/src/frizz-md.ts
12986
+ var FrizzMdLanding, FrizzMdAutonomy, FRIZZ_MD_NOTES_MAX, FrizzMdAnswers, FrizzMdStatus;
12987
+ var init_frizz_md = __esm({
12988
+ "packages/shared/src/frizz-md.ts"() {
12989
+ "use strict";
12990
+ init_zod();
12991
+ FrizzMdLanding = external_exports.enum(["pull-request", "main", "main-push", "branch"]);
12992
+ FrizzMdAutonomy = external_exports.enum(["decide", "balanced", "check-in"]);
12993
+ FRIZZ_MD_NOTES_MAX = 8e3;
12994
+ FrizzMdAnswers = external_exports.object({
12995
+ landing: FrizzMdLanding,
12996
+ autonomy: FrizzMdAutonomy,
12997
+ notes: external_exports.string().max(FRIZZ_MD_NOTES_MAX)
12998
+ }).strict();
12999
+ FrizzMdStatus = external_exports.object({
13000
+ /** A FRIZZ.md is already at the project root: the project has its rules, so nothing is asked. */
13001
+ exists: external_exports.boolean(),
13002
+ /** The operator skipped the questionnaire for this project: it is not asked again. */
13003
+ skipped: external_exports.boolean(),
13004
+ /** The branch the landing rules name — the remote's default, else the checked-out one, else `main`. */
13005
+ defaultBranch: external_exports.string()
13006
+ });
13007
+ }
13008
+ });
13009
+
12365
13010
  // packages/shared/src/receipt-bus.ts
12366
13011
  var init_receipt_bus = __esm({
12367
13012
  "packages/shared/src/receipt-bus.ts"() {
@@ -12425,9 +13070,6 @@ var init_relay_protocol = __esm({
12425
13070
  RELAY_MAX_FRAME_BODY = 512 * 1024;
12426
13071
  RELAY_MAX_WS_CHUNK = 64 * 1024;
12427
13072
  WsMessageAssembler = class {
12428
- constructor(maxBytes = 8 * 1024 * 1024) {
12429
- this.maxBytes = maxBytes;
12430
- }
12431
13073
  parts = /* @__PURE__ */ new Map();
12432
13074
  sizes = /* @__PURE__ */ new Map();
12433
13075
  /**
@@ -12438,6 +13080,10 @@ var init_relay_protocol = __esm({
12438
13080
  * lighter and corrupt. The run stays poisoned until the frame that terminates it.
12439
13081
  */
12440
13082
  poisoned = /* @__PURE__ */ new Set();
13083
+ maxBytes;
13084
+ constructor(maxBytes = 8 * 1024 * 1024) {
13085
+ this.maxBytes = maxBytes;
13086
+ }
12441
13087
  /** Returns the complete message, or null while more is still to come (or once the run overflowed). */
12442
13088
  push(id, data, more) {
12443
13089
  if (this.poisoned.has(id)) {
@@ -12504,22 +13150,27 @@ function signoffNudgeAwaitingLines(needsInput) {
12504
13150
  return [
12505
13151
  "- `` ```awaiting `` \u2014 you are WAITING on work that is actually running: a background shell, a",
12506
13152
  " sub-agent, a timer or a registered PR. FRONTMATTER, THEN MARKDOWN: one YAML list per kind of thing",
12507
- " you are waiting on, a REQUIRED `for:` duration, a REQUIRED `needs_input:` answer, then optionally a",
12508
- " `---` line and prose.",
13153
+ " you are waiting on, a REQUIRED `for:` duration, a REQUIRED `status:`, then optionally a `---` line",
13154
+ " and prose.",
12509
13155
  "",
12510
13156
  " ```awaiting",
12511
13157
  " agents: [<the id your runtime gave you>]",
12512
- " needs_input: false",
13158
+ " status: working",
12513
13159
  " for: 2h",
12514
13160
  " ```",
12515
13161
  "",
12516
- " `needs_input: false` \u2014 nothing for the human yet. The thread stays out of their queue until the work",
12517
- " wakes you, and you owe NO write-up: the fence alone is the whole message. `needs_input: true` \u2014 the",
12518
- " human can read, try or act on something NOW while the work runs (a partial result, a file you wrote,",
12519
- " a server to try), even if you need nothing back from them; the thread goes into their queue, and the",
12520
- " prose under `---` says what to look at. If you wrote ANY words for the human at this rest, it is",
12521
- " `true` \u2014 a `false` rest is never put in front of them. A rest on running work with NO fence is a",
12522
- " bare rest, and it lands in the human's queue."
13162
+ " `status:` says where the thread sits while you wait, in one of three words:",
13163
+ " - `working` \u2014 what you named finishes by itself: a test, a build, a benchmark, CI, a sub-agent doing",
13164
+ " a task. The thread shows as running until the work wakes you.",
13165
+ " - `watching` \u2014 something has to HAPPEN outside the thread: a release, a review, another agent's",
13166
+ " merge, the next daily cycle. The thread is snoozed, and nothing on its row moves, until a named",
13167
+ " item reports or `for:` runs out.",
13168
+ " - `needs_input` \u2014 the human can read, try or act on something NOW while the work runs (a partial",
13169
+ " result, a file you wrote, a server to try), even if you need nothing back from them. The thread",
13170
+ " goes into their queue, and the prose under `---` says what to look at.",
13171
+ " `working` and `watching` owe NO write-up: the fence alone is the whole message. If you wrote ANY",
13172
+ " words for the human at this rest, it is `needs_input` \u2014 nothing else is put in front of them. A rest",
13173
+ " on running work with NO fence is a bare rest, and it lands in the human's queue."
12523
13174
  ];
12524
13175
  }
12525
13176
  function signoffNudgeText(needsInput) {
@@ -12565,10 +13216,20 @@ function signoffNudgeText(needsInput) {
12565
13216
  " A fence that names NOTHING is not a park at all \u2014 if you are not waiting on anything, you are not",
12566
13217
  " awaiting, you are done. Register a PR with `mcp__frizz__watch_pr` and a timer with",
12567
13218
  " `mcp__frizz__timer`; `mcp__frizz__activity` reads back everything you have running, with its id.",
13219
+ "- `` ```awaiting `` with `steps:` \u2014 the human must PERFORM something you cannot: sign in, approve,",
13220
+ " merge, press a button you may not. List each step as a `- ` line under `steps:`, written to be",
13221
+ " followed cold: a real Markdown link to every page and file it names, every command in a code span,",
13222
+ " every value the act needs written in. Frizz reads them verbatim. Steps name the HUMAN as the wait,",
13223
+ " so the fence needs no other name and no `for:`, and the thread goes into their queue. Their Done",
13224
+ " comes back to you as their reply; anything else they need to say comes as a message of their own.",
13225
+ "- `` ```awaiting `` with `questions:` \u2014 a question you registered at an EARLIER rest is still open and",
13226
+ " you still need its answer. Name every such question (`questions: [qst_\u2026]`) and withdraw the rest with",
13227
+ " `mcp__frizz__unask`: a fence that leaves an open question out is refused. A named card is drawn at",
13228
+ " this rest; an unnamed one stays where you asked it.",
12568
13229
  "",
12569
13230
  "**STILL OWED counts things you are not going to do yourself.** A decision you are RECOMMENDING, a",
12570
13231
  "draft you wrote but did not send, follow-up work you discovered \u2014 all of it dies with the card, even",
12571
- "the part that is someone else's to do. Each ends on a question with your recommendation as option A,",
13232
+ "the part that is someone else's to do. Each ends on a question with your recommendation as option 1,",
12572
13233
  "or you DO it first \u2014 a sub-agent's result comes BACK to you, so it lands on your card; a new card via",
12573
13234
  "`mcp__frizz__spawn_thread` is the LAST resort, since nothing it learns returns to you or its siblings.",
12574
13235
  "None of them is a `done`. And what is not",
@@ -12593,7 +13254,7 @@ function requireEffortOutsideAcp(profile, ctx) {
12593
13254
  function fallbackPort(base) {
12594
13255
  return base + 1e4;
12595
13256
  }
12596
- var import_yaml, ATTACHMENT_IMAGE_EXTENSIONS, ATTACHMENT_TEXT_EXTENSIONS, ATTACHMENT_OFFICE_EXTENSIONS, ATTACHMENT_DATA_EXTENSIONS, ATTACHMENT_ARCHIVE_EXTENSIONS, ATTACHMENT_DOC_EXTENSIONS, ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BASE64_CHARS, ATTACHMENT_MAX_BYTES, ATTACHMENT_EXT_SET, ATTACHMENT_ACCEPT, FrizzStatus, BlockMechanism, RuntimeState, Backend, CodexModel, ClaudeModel, AcpAgent, AcpAgentModel, AcpAgentModels, AcpAgentModelsInput, ThreadProfileOption, ThreadAgent, SubAgentView, BgShellView, CompletionHoldOp, CompletionHold, AskOption, AskQuestion, PendingAsk, AwaitingHint, RETIRED_AWAITING_KINDS, RETIRED_LINE_RE, AWAITING_FOR_MAX_MS, SnoozeUntil, SNOOZE_PROMPT_MAX, SnoozePrompt, RECURRING_PROMPT_MAX, RECURRING_MIN_INTERVAL_SECONDS, RECURRING_MAX_INTERVAL_SECONDS, RecurringPromptText, RecurringIntervalSeconds, ThreadRecurringPrompt, TIMER_PROMPT_MAX, TIMER_MAX_DELAY_SECONDS, TimerPromptText, SIGNOFF_NUDGE_MARKER, SIGNOFF_NUDGE_MESSAGE, SIGNOFF_NUDGE_MESSAGE_NEEDS_INPUT, ThreadWatchKind, GithubChecksState, GithubMergeState, GithubWatchStatus, GithubIssueStatus, ThreadWatchView, PR_WATCH_FOR_MAX_MS, PR_WATCH_DEFAULT_FOR_MS, PrWatchView, AddOwnPrWatchInput, AddOwnPrWatchResult, DropOwnPrWatchInput, DropOwnPrWatchResult, OwnWatchKind, AddOwnWatchInput, OwnWatchView, AddOwnWatchResult, DropOwnWatchInput, DropOwnWatchResult, AskQuestionKind, AskedOptionSchema, AskedQuestionSchema, AskInput, RegisteredQuestionView, AskResult, UnaskInput, UnaskResult, QuestionAnswerSchema, AnswerQuestionsInput, AnswerQuestionsResult, DismissQuestionsInput, DismissQuestionsResult, SettledQuestionView, ThreadSettledQuestionsResult, MarkOwnDoneInput, MarkOwnDoneResult, ListOwnPrWatchesInput, OwnPrWatchesResult, ThreadTimerView, ThreadFence, LimitWindow, LimitPause, ProviderError, ThreadLinkView, UpsertOwnLinkInput, UpsertOwnLinkResult, DropOwnLinkInput, DropOwnLinkResult, LiveTool, ThreadView, BoardErrorItem, BoardSnapshot, QuotaWindow, ProviderQuota, QuotaSnapshot, ProviderAuth, AccountEmails, AuthSnapshot, AccountBackend, AccountLogoutInput, AccountLogoutResult, PermissionMode, LocalFileOpener, Settings, DispatchProviderPreferences, DispatchPreferences, DispatchProfileSnapshotShape, DispatchProfileSnapshot, SetDispatchPreferenceInput, DispatchInput, ADOPT_THREAD_MESSAGE_MAX_CHARS, AdoptThreadInput, AdoptThreadResult, AdoptSessionInput, FollowUpInput, DismissFailedFollowUpInput, DismissFailedFollowUpResult, UnqueueFollowUpInput, UnqueueFollowUpResult, DeliverQueuedNowInput, DeliverQueuedNowResult, CompactThreadInput, SetThreadSnoozeInput, SetThreadPinnedInput, SetThreadRecurringPromptInput, SetOwnThreadRecurringPromptInput, SetOwnThreadRecurringPromptResult, GetOwnThreadRecurringPromptInput, OwnThreadRecurringPromptResult, SetOwnThreadTimerInput, CancelOwnThreadTimerInput, ListOwnThreadTimersInput, OwnThreadTimersResult, ThreadActivityItem, ListOwnThreadActivityInput, OwnThreadActivityResult, SetOwnThreadTimerResult, CancelOwnThreadTimerResult, SetOwnThreadStopHookInput, SetOwnThreadHeartbeatInput, ThreadPluginReloadResult, RenameThreadInput, AiRenameThreadInput, AiRenameThreadResult, SetOwnThreadTitleInput, SetOwnThreadTitleResult, SetThreadPermissionInput, SetThreadPermissionResult, ThreadProfileOptionsInput, ThreadProfileOptionsResult, ThreadSkillSource, ThreadSkill, ThreadSkillsInput, ThreadSkillsResult, SetThreadProfileInput, SetThreadProfileResult, UpgradeThreadModelInput, UpgradeThreadModelResult, DISPATCH_TASK_BANNER, DISPATCH_TASK_BANNER_MARKER, HUMAN_GAP_FLOOR_MS, GithubWakeItem, GithubWakeSteer, WAKE_SCOPE, WAKE_REF, WAKE_SINGLE, WAKE_MULTI_HEAD, PR_WATCH_FINISHED, PR_WATCH_CI_PASSED, PR_WATCH_CI_GATED, PR_WATCH_STATE, PR_WATCH_CI_FAILED, GithubStatus, GithubRefCard, GithubRefPreviewInput, GithubRefPreviewResult, GithubItem, GithubListInput, GithubListResult, GithubBatchInput, GithubBatchResult, BoardMeta, ServerEvent, TranscriptEdit, TranscriptTodo, TranscriptToolCall, TranscriptPart, TranscriptMessage, TranscriptPageCursor, EditedFile, TranscriptPage, TranscriptEarlierInput, SocketTranscriptSlug, SocketFilePath, SocketTranscriptClientMsg, SocketFileClientMsg, SocketClientMsg, ProjectCard, ProjectRailCounts, PROJECT_ICON_MAX_BASE64_CHARS, DirectoryPickResult, ThreadLocation, FRIZZ_ROUTE_PREFIX, DEFAULT_PORT;
13257
+ var import_yaml, ATTACHMENT_IMAGE_EXTENSIONS, ATTACHMENT_TEXT_EXTENSIONS, ATTACHMENT_OFFICE_EXTENSIONS, ATTACHMENT_DATA_EXTENSIONS, ATTACHMENT_ARCHIVE_EXTENSIONS, ATTACHMENT_DOC_EXTENSIONS, ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BASE64_CHARS, ATTACHMENT_MAX_BYTES, ATTACHMENT_EXT_SET, ATTACHMENT_ACCEPT, FrizzStatus, BlockMechanism, RuntimeState, Backend, CodexModel, ClaudeModel, AcpAgent, AcpAgentModel, AcpAgentModels, AcpAgentModelsInput, ThreadProfileOption, ThreadAgent, SubAgentView, BgShellView, CompletionHoldOp, CompletionHold, AskOption, AskQuestion, PendingAsk, AwaitingHint, RETIRED_AWAITING_KINDS, RETIRED_LINE_RE, AWAITING_FOR_MAX_MS, SnoozeUntil, SNOOZE_PROMPT_MAX, SnoozePrompt, RECURRING_PROMPT_MAX, RECURRING_MIN_INTERVAL_SECONDS, RECURRING_MAX_INTERVAL_SECONDS, RecurringPromptText, RecurringIntervalSeconds, ThreadRecurringPrompt, TIMER_PROMPT_MAX, TIMER_MAX_DELAY_SECONDS, TimerPromptText, SIGNOFF_NUDGE_MARKER, SIGNOFF_NUDGE_MESSAGE, SIGNOFF_NUDGE_MESSAGE_NEEDS_INPUT, ThreadWatchKind, GithubChecksState, GithubMergeState, GithubWatchStatus, GithubIssueStatus, ThreadWatchView, PR_WATCH_FOR_MAX_MS, PR_WATCH_DEFAULT_FOR_MS, PrWatchView, AddOwnPrWatchInput, AddOwnPrWatchResult, DropOwnPrWatchInput, DropOwnPrWatchResult, OwnWatchKind, AddOwnWatchInput, OwnWatchView, AddOwnWatchResult, DropOwnWatchInput, DropOwnWatchResult, AskQuestionKind, AskedOptionSchema, AskedQuestionSchema, AskInput, RegisteredQuestionView, SECRET_TTL_MS, AskResult, UnaskInput, UnaskResult, QuestionAnswerSchema, AnswerQuestionsInput, AnswerQuestionsResult, DismissQuestionsInput, DismissQuestionsResult, SettledQuestionView, ThreadSettledQuestionsResult, MarkOwnDoneInput, MarkOwnDoneResult, ListOwnPrWatchesInput, OwnPrWatchesResult, ThreadTimerView, ThreadFence, LimitWindow, LimitPause, ProviderError, ThreadLinkView, UpsertOwnLinkInput, UpsertOwnLinkResult, DropOwnLinkInput, DropOwnLinkResult, LiveTool, ThreadView, BoardErrorItem, BoardSnapshot, QuotaWindow, ProviderQuota, QuotaSnapshot, ProviderAuth, AccountEmails, AuthSnapshot, AccountBackend, AccountLogoutInput, AccountLogoutResult, PermissionMode, LocalFileOpener, Settings, DispatchProviderPreferences, DispatchPreferences, DispatchProfileSnapshotShape, DispatchProfileSnapshot, SetDispatchPreferenceInput, DispatchInput, ADOPT_THREAD_MESSAGE_MAX_CHARS, AdoptThreadInput, AdoptThreadResult, AdoptSessionInput, FollowUpInput, DismissFailedFollowUpInput, DismissFailedFollowUpResult, UnqueueFollowUpInput, UnqueueFollowUpResult, DeliverQueuedNowInput, DeliverQueuedNowResult, CompactThreadInput, SetThreadSnoozeInput, SetThreadPinnedInput, SetThreadRecurringPromptInput, SetOwnThreadRecurringPromptInput, SetOwnThreadRecurringPromptResult, GetOwnThreadRecurringPromptInput, OwnThreadRecurringPromptResult, SetOwnThreadTimerInput, CancelOwnThreadTimerInput, ListOwnThreadTimersInput, OwnThreadTimersResult, ThreadActivityItem, ListOwnThreadActivityInput, OwnThreadActivityResult, SetOwnThreadTimerResult, CancelOwnThreadTimerResult, SetOwnThreadStopHookInput, SetOwnThreadHeartbeatInput, ThreadPluginReloadResult, RenameThreadInput, AiRenameThreadInput, AiRenameThreadResult, SetOwnThreadTitleInput, SetOwnThreadTitleResult, SetThreadPermissionInput, SetThreadPermissionResult, ThreadProfileOptionsInput, ThreadProfileOptionsResult, ThreadSkillSource, ThreadSkill, ThreadSkillsInput, ThreadSkillsResult, SetThreadProfileInput, SetThreadProfileResult, UpgradeThreadModelInput, UpgradeThreadModelResult, DISPATCH_TASK_BANNER, DISPATCH_TASK_BANNER_MARKER, HUMAN_GAP_FLOOR_MS, GithubWakeItem, GithubWakeSteer, WAKE_SCOPE, WAKE_REF, WAKE_SINGLE, WAKE_MULTI_HEAD, PR_WATCH_FINISHED, PR_WATCH_CI_PASSED, PR_WATCH_CI_GATED, PR_WATCH_STATE, PR_WATCH_CI_FAILED, GithubStatus, GithubRefCard, GithubRefPreviewInput, GithubRefPreviewResult, GithubItem, GithubListInput, GithubListResult, GithubBatchInput, GithubBatchResult, BoardMeta, ServerEvent, TranscriptEdit, TranscriptTodo, TranscriptToolCall, TranscriptPart, TranscriptMessage, TranscriptPageCursor, EditedFile, TranscriptPage, TranscriptEarlierInput, SocketTranscriptSlug, SocketFilePath, SocketTranscriptClientMsg, SocketFileClientMsg, SocketClientMsg, ProjectCard, ProjectRailCounts, PROJECT_ICON_MAX_BASE64_CHARS, DirectoryPickResult, ThreadLocation, FRIZZ_ROUTE_PREFIX, DEFAULT_PORT;
12597
13258
  var init_src = __esm({
12598
13259
  "packages/shared/src/index.ts"() {
12599
13260
  "use strict";
@@ -12606,6 +13267,7 @@ var init_src = __esm({
12606
13267
  init_code_fences();
12607
13268
  init_delta();
12608
13269
  init_drainable_worker();
13270
+ init_frizz_md();
12609
13271
  init_interactions();
12610
13272
  init_receipt_bus();
12611
13273
  init_relay_protocol();
@@ -12931,7 +13593,7 @@ var init_src = __esm({
12931
13593
  questions: external_exports.array(AskQuestion)
12932
13594
  });
12933
13595
  AwaitingHint = external_exports.object({
12934
- kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "for", "title", "needs_input"]),
13596
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "for", "title", "status", "needs_input", "step", "question"]),
12935
13597
  value: external_exports.string()
12936
13598
  });
12937
13599
  RETIRED_AWAITING_KINDS = ["watch", "pr-watch", "human", "ci", "session", "shell", "agent", "timer", "pr", "reason"];
@@ -13199,10 +13861,11 @@ var init_src = __esm({
13199
13861
  header: external_exports.string().trim().max(24).optional(),
13200
13862
  kind: AskQuestionKind,
13201
13863
  danger: external_exports.boolean().optional(),
13864
+ secret: external_exports.boolean().optional(),
13202
13865
  // UNBOUNDED, deliberately. This carried `.max(8)` from launch until 2026-09-03, when the maintainer
13203
13866
  // asked for the cap to go ("allow arbitrary numbers of options"): a `multi` over a long list — which
13204
- // gates to run, which of twenty findings to act on — is a real shape, and the card letters past 26
13205
- // (`AA.`) already. The count is the worker's to choose; the answer's `chosen` is unbounded to match.
13867
+ // gates to run, which of twenty findings to act on — is a real shape, and the card numbers past 9
13868
+ // (only 1–9 are keys) already. The count is the worker's to choose; the answer's `chosen` is unbounded to match.
13206
13869
  options: external_exports.array(AskedOptionSchema).optional()
13207
13870
  }).strict());
13208
13871
  AskInput = external_exports.object({
@@ -13216,8 +13879,12 @@ var init_src = __esm({
13216
13879
  * an id alone cannot be correlated back to what was asked. */
13217
13880
  id: external_exports.string(),
13218
13881
  spec: AskedQuestionSchema,
13219
- askedAt: external_exports.string()
13882
+ askedAt: external_exports.string(),
13883
+ /** A `secret` question only: the path its value will be served at. Known at registration, so the
13884
+ * worker can prepare the command that reads it before the human has answered. */
13885
+ secretPath: external_exports.string().optional()
13220
13886
  }).strict();
13887
+ SECRET_TTL_MS = 15 * 6e4;
13221
13888
  AskResult = external_exports.object({
13222
13889
  registered: external_exports.array(RegisteredQuestionView),
13223
13890
  /** Everything still open on this thread afterwards, so a worker never needs a second call. */
@@ -13532,6 +14199,13 @@ var init_src = __esm({
13532
14199
  // Optional like needsYou/crashed: absent ⇒ a pre-restart server or a non-session row; the client
13533
14200
  // treats absence as false.
13534
14201
  awaitingBackground: external_exports.boolean().optional(),
14202
+ // SERVER-DERIVED: which band a rest OUT OF THE QUEUE sits in (board.deriveWaitStatus, 2026-10-05).
14203
+ // `working` — the work it waits on finishes by itself, so it is Running and spins; `watching` —
14204
+ // something must happen outside the thread, so it is Snoozed and still. Present only for a thread at
14205
+ // rest behind a wait the server honoured with no queue card; absent everywhere else, including every
14206
+ // queued rest, which never spins. Absent from a pre-2026-10-05 server, which the client reads as the
14207
+ // old Running placement.
14208
+ waitStatus: external_exports.enum(["working", "watching"]).optional(),
13535
14209
  // Exact typed-interaction presence for this CURRENT registered session. The board already derives
13536
14210
  // this from the scoped durable journal to compute needsYou; exposing the reason lets React avoid a
13537
14211
  // pendingInteractions RPC for every unrelated question/completion card. Optional preserves rolling
@@ -13600,7 +14274,7 @@ var init_src = __esm({
13600
14274
  // further to do; otherwise the composer offers the upgrade, and the first message after the thread's
13601
14275
  // next compaction takes it on its own.
13602
14276
  modelUpgrade: external_exports.object({ label: external_exports.string(), staged: external_exports.boolean() }).optional(),
13603
- // How full the session's context window is right now — the footer's fullness readout. BOTH halves
14277
+ // How full the session's context window is right now — the header's fullness readout. BOTH halves
13604
14278
  // are provider-measured and the field is emitted ONLY when both are present, so a client never has
13605
14279
  // to decide what to do with half a fraction: absent ⇒ no reading, never a 0% dial. Codex reports
13606
14280
  // both on every `token_count`; a Claude row gets `tokens` from each assistant record's usage but
@@ -14702,14 +15376,588 @@ ${DISPATCH_TASK_BANNER}
14702
15376
  }
14703
15377
  });
14704
15378
 
14705
- // packages/server/src/frizz-paths.ts
14706
- import { existsSync } from "node:fs";
14707
- import { homedir as homedir2, tmpdir } from "node:os";
14708
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve, sep as sep2 } from "node:path";
14709
- function legacyFrizzRoot(home = homedir2()) {
14710
- return join3(home, LEGACY_DIR_NAME);
14711
- }
14712
- function xdg(env, name) {
15379
+ // packages/server/src/restart-supervisor.ts
15380
+ import { request as requestHttp, createServer } from "node:http";
15381
+ import { connect } from "node:net";
15382
+ import { pipeline } from "node:stream";
15383
+ function responseJson(res, status, value) {
15384
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
15385
+ res.end(JSON.stringify(value));
15386
+ }
15387
+ function proxyHeaders(req, childPort, vouchSameOrigin = false) {
15388
+ const headers = { ...req.headers };
15389
+ headers.host = `127.0.0.1:${childPort}`;
15390
+ if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${childPort}`;
15391
+ for (const name of FORWARDED_HEADERS) delete headers[name];
15392
+ if (vouchSameOrigin) headers["sec-fetch-site"] = "same-origin";
15393
+ delete headers.connection;
15394
+ return headers;
15395
+ }
15396
+ function readCookie(header, name) {
15397
+ if (!header) return void 0;
15398
+ for (const pair of header.split(";")) {
15399
+ const eq = pair.indexOf("=");
15400
+ if (eq === -1) continue;
15401
+ if (pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim();
15402
+ }
15403
+ return void 0;
15404
+ }
15405
+ function isControlRequest(req) {
15406
+ const url = new URL(req.url ?? "/", "http://frizz.invalid");
15407
+ return url.pathname === SUPERVISOR_RESTART_PATH || url.pathname === SUPERVISOR_UPDATE_RESTART_PATH || url.pathname === SUPERVISOR_STATUS_PATH || url.pathname === SUPERVISOR_ACCESS_CODE_PATH || url.pathname === SUPERVISOR_SESSIONS_PATH || url.pathname === SUPERVISOR_SIGN_OUT_PATH;
15408
+ }
15409
+ function isLocalImageRequest(req) {
15410
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
15411
+ const { pathname } = new URL(req.url ?? "/", "http://frizz.invalid");
15412
+ return LOCAL_IMAGE_PATH.test(pathname);
15413
+ }
15414
+ async function readJsonBody(req, limit = 64 * 1024) {
15415
+ const chunks = [];
15416
+ let size = 0;
15417
+ for await (const chunk of req) {
15418
+ size += chunk.byteLength;
15419
+ if (size > limit) return null;
15420
+ chunks.push(chunk);
15421
+ }
15422
+ if (chunks.length === 0) return {};
15423
+ try {
15424
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
15425
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
15426
+ } catch {
15427
+ return null;
15428
+ }
15429
+ }
15430
+ var SUPERVISOR_CONTROL_PREFIX, SUPERVISOR_RESTART_PATH, SUPERVISOR_UPDATE_RESTART_PATH, SUPERVISOR_STATUS_PATH, SUPERVISOR_ACCESS_CODE_PATH, SUPERVISOR_SESSIONS_PATH, SUPERVISOR_SIGN_OUT_PATH, SUPERVISOR_CONTROL_PROTOCOL, ACCESS_CODE_PARAM, SESSION_COOKIE, LOOPBACK_HOSTNAMES, LOCAL_IMAGE_PATH, RestartSupervisorProxy;
15431
+ var init_restart_supervisor = __esm({
15432
+ "packages/server/src/restart-supervisor.ts"() {
15433
+ "use strict";
15434
+ init_access_codes();
15435
+ init_local_origin();
15436
+ init_local_image();
15437
+ init_supervisor_pages();
15438
+ init_src();
15439
+ SUPERVISOR_CONTROL_PREFIX = "/_frizz/control";
15440
+ SUPERVISOR_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/restart`;
15441
+ SUPERVISOR_UPDATE_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/update-restart`;
15442
+ SUPERVISOR_STATUS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/status`;
15443
+ SUPERVISOR_ACCESS_CODE_PATH = `${SUPERVISOR_CONTROL_PREFIX}/access-code`;
15444
+ SUPERVISOR_SESSIONS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/sessions`;
15445
+ SUPERVISOR_SIGN_OUT_PATH = `${SUPERVISOR_CONTROL_PREFIX}/sign-out`;
15446
+ SUPERVISOR_CONTROL_PROTOCOL = 1;
15447
+ ACCESS_CODE_PARAM = "frizz_code";
15448
+ SESSION_COOKIE = "frizz_session";
15449
+ LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
15450
+ LOCAL_IMAGE_PATH = new RegExp(`^${FRIZZ_ROUTE_PREFIX}(?:/[^/]+)?/local-image$`);
15451
+ RestartSupervisorProxy = class {
15452
+ server = null;
15453
+ restartInFlight = null;
15454
+ /** Which action `restartInFlight` is. An update's prepare phase leaves the child serving; a restart does not. */
15455
+ transition = null;
15456
+ state = "ready";
15457
+ message;
15458
+ options;
15459
+ host;
15460
+ /**
15461
+ * The authorities a browser may name for the PUBLIC port. This proxy is the whole boundary: it
15462
+ * rewrites Host/Origin to the child's private loopback authority, so the child's own strict gate
15463
+ * can never see (or reject) the real browser authority. Everything this class forwards has to be
15464
+ * judged here first.
15465
+ */
15466
+ policy;
15467
+ /** The origin a tunnel or proxy serves this board at; undefined while loopback-only. Live: see setPublicOrigin. */
15468
+ publicOrigin;
15469
+ /**
15470
+ * Codes and sessions for the public origin, or null when no public origin is declared (loopback-only
15471
+ * boards are never gated, so there is nothing to store).
15472
+ */
15473
+ access;
15474
+ /** Both halves of every live upgraded pair, so close() can actually finish. See close(). */
15475
+ upgradedSockets = /* @__PURE__ */ new Set();
15476
+ constructor(options2) {
15477
+ this.options = options2;
15478
+ this.host = options2.host ?? LOOPBACK_BIND_HOST;
15479
+ this.publicOrigin = options2.publicOrigin;
15480
+ this.access = new AccessStore({
15481
+ onConsumed: () => options2.onCodeConsumed?.(),
15482
+ ...options2.sessionKey ? { signingKey: options2.sessionKey } : {},
15483
+ ...options2.rotateSessionKey ? { rotateSigningKey: options2.rotateSessionKey } : {},
15484
+ ...options2.sessionDirectory ? { sessions: options2.sessionDirectory } : {}
15485
+ });
15486
+ this.policy = this.buildPolicy();
15487
+ }
15488
+ /**
15489
+ * Change the public origin on a running board — what the R pane does when the operator picks or
15490
+ * clears a remote setup. Requests already in flight finish under the policy they started with; the
15491
+ * next one is judged by the new origin. Sessions survive the change.
15492
+ */
15493
+ setPublicOrigin(origin) {
15494
+ this.publicOrigin = origin;
15495
+ this.policy = this.buildPolicy();
15496
+ }
15497
+ buildPolicy() {
15498
+ return {
15499
+ exposed: bindHostIsExposed(this.host),
15500
+ allowedHosts: this.options.allowedHosts ?? [],
15501
+ ...this.publicOrigin ? { publicOrigin: this.publicOrigin } : {}
15502
+ };
15503
+ }
15504
+ /**
15505
+ * Mint a single-use code for this board, or null when no public origin is declared.
15506
+ *
15507
+ * Issuing does NOT invalidate codes already outstanding — pressing the key twice, or two people
15508
+ * asking at once, must not silently break the first QR somebody is still walking towards.
15509
+ */
15510
+ issueAccessCode() {
15511
+ return this.publicOrigin ? this.access.issue() : null;
15512
+ }
15513
+ /** The full URL to show as a link or a QR. */
15514
+ accessUrl(code) {
15515
+ const origin = this.publicOrigin;
15516
+ return origin ? `${origin}/?${ACCESS_CODE_PARAM}=${encodeURIComponent(code)}` : null;
15517
+ }
15518
+ async listen() {
15519
+ if (this.server) return;
15520
+ const server = createServer((req, res) => this.handle(req, res));
15521
+ server.keepAliveTimeout = 5e3;
15522
+ server.headersTimeout = 1e4;
15523
+ server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head));
15524
+ await new Promise((resolveListen, rejectListen) => {
15525
+ server.once("error", rejectListen);
15526
+ server.listen(this.options.port, this.host, () => {
15527
+ server.off("error", rejectListen);
15528
+ resolveListen();
15529
+ });
15530
+ });
15531
+ this.server = server;
15532
+ }
15533
+ /**
15534
+ * Is this request allowed to reach the child at all?
15535
+ *
15536
+ * Deliberately the WEAKER of the two gates: a missing Origin passes here and the child then applies
15537
+ * its own per-route rule for it (only /health, /control/stop and `Sec-Fetch-Site: same-origin` may
15538
+ * omit one). What this stops is the thing the child cannot: an Origin naming somebody ELSE being
15539
+ * laundered into a valid same-origin one by proxyHeaders below.
15540
+ */
15541
+ authorityAccepted(req, allowMissingOrigin = true) {
15542
+ return isTrustedLocalHttpRequest(req.headers, this.options.port, allowMissingOrigin, this.policy);
15543
+ }
15544
+ /**
15545
+ * Is this an Origin-less request that only LOOKS suspicious because the browser could not send
15546
+ * Fetch Metadata to a non-loopback authority? See authoritySendsFetchMetadata.
15547
+ *
15548
+ * Frizz's missing-Origin rules ask for `Sec-Fetch-Site: same-origin`, which Chrome never sends over
15549
+ * plain HTTP to a LAN address — so without this, `--host` serves the shell and then 403s every RPC
15550
+ * the app makes. Narrow on purpose: the Host must already be an authority this proxy accepts, there
15551
+ * must be no Origin at all (a present one still has to match, and is rejected above if it does not),
15552
+ * and loopback traffic is never touched, so the default posture keeps the real Sec-Fetch signal.
15553
+ *
15554
+ * The condition is `authoritySendsFetchMetadata`, not "is the bind address exposed", because those
15555
+ * two came apart with `--public-origin`: a tunnelled board stays on loopback yet is reached at a
15556
+ * name. An https proxy origin is potentially trustworthy, so the real signal survives and this
15557
+ * never fires for it; a plain-http one is in the same boat as a LAN address and needs the vouch.
15558
+ *
15559
+ * What this gives up, stated plainly: on an exposed board a cross-site GET carries no Origin and no
15560
+ * Fetch Metadata, so it is indistinguishable from the app's own read and is allowed. The response is
15561
+ * still opaque to the caller (no CORS), Frizz's GET procedures are reads, and every mutation is a POST
15562
+ * — which a browser always stamps with an Origin, and which is therefore still refused.
15563
+ */
15564
+ vouchesSameOrigin(req) {
15565
+ if (req.headers.origin !== void 0 || req.headers["sec-fetch-site"] !== void 0) return false;
15566
+ const host = parseLocalHost(req.headers.host, this.options.port, this.policy);
15567
+ return !!host && !authoritySendsFetchMetadata(host);
15568
+ }
15569
+ async close() {
15570
+ const server = this.server;
15571
+ this.server = null;
15572
+ if (!server) return;
15573
+ await new Promise((resolveClose, rejectClose) => {
15574
+ server.close((error) => error ? rejectClose(error) : resolveClose());
15575
+ server.closeAllConnections();
15576
+ for (const socket of this.upgradedSockets) socket.destroy();
15577
+ this.upgradedSockets.clear();
15578
+ });
15579
+ }
15580
+ status() {
15581
+ const delegated = this.options.status?.();
15582
+ const updateVersion = this.options.updateVersion?.();
15583
+ const version = typeof this.options.version === "function" ? this.options.version() : this.options.version;
15584
+ const owner = { state: this.state, ...this.message ? { message: this.message } : {} };
15585
+ const reported = this.transition === "update" && delegated ? { ...delegated, ...delegated.state === "ready" ? { preparing: true } : {} } : this.restartInFlight !== null || this.state === "failed" ? owner : delegated ?? owner;
15586
+ return {
15587
+ ...delegated ?? {},
15588
+ ...reported,
15589
+ // Never infer this from the generic protocol: legacy/static supervisors can recover a child
15590
+ // but cannot build and promote the canonical Frizz artifact.
15591
+ updateRestart: typeof this.options.updateRestart === "function",
15592
+ // Sent ONLY when the launcher can actually answer it, so an older client — and frizz-dev, which
15593
+ // has no notion of "already current" — keeps today's behaviour on its absence.
15594
+ ...this.options.updateAvailable ? { updateAvailable: this.options.updateAvailable() === true } : {},
15595
+ // The active SERVER version changes on a child-only update. Its absence keeps frizz-dev and
15596
+ // old monolithic supervisors' status payload and versionless UI exactly as they were.
15597
+ ...version ? { version } : {},
15598
+ ...this.options.launcherVersion ? { launcherVersion: this.options.launcherVersion } : {},
15599
+ ...updateVersion ? { updateVersion } : {},
15600
+ // Sent only when TRUE, so a published Frizz's payload is byte-identical to what it sends today
15601
+ // and an older client is unaffected. Absent therefore means "not a development build".
15602
+ ...this.options.dev ? { dev: true } : {}
15603
+ };
15604
+ }
15605
+ async runAction(action, transition) {
15606
+ if (this.restartInFlight) return this.restartInFlight;
15607
+ this.transition = transition;
15608
+ this.state = "restarting";
15609
+ this.message = void 0;
15610
+ const work = (async () => {
15611
+ await new Promise((resume) => setImmediate(resume));
15612
+ return action();
15613
+ })().then(
15614
+ (result) => {
15615
+ this.state = result.state;
15616
+ this.message = result.message;
15617
+ return result;
15618
+ },
15619
+ (error) => {
15620
+ const result = { state: "failed", message: error instanceof Error ? error.message : String(error) };
15621
+ this.state = result.state;
15622
+ this.message = result.message;
15623
+ return result;
15624
+ }
15625
+ ).finally(() => {
15626
+ this.restartInFlight = null;
15627
+ this.transition = null;
15628
+ });
15629
+ this.restartInFlight = work;
15630
+ return work;
15631
+ }
15632
+ async handleControl(req, res) {
15633
+ const pathname = new URL(req.url ?? "/", "http://frizz.invalid").pathname;
15634
+ if (pathname !== SUPERVISOR_SIGN_OUT_PATH && this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
15635
+ res.writeHead(401, { "cache-control": "no-store" });
15636
+ res.end();
15637
+ return;
15638
+ }
15639
+ const sameOrigin = req.headers["sec-fetch-site"] === "same-origin" || this.vouchesSameOrigin(req);
15640
+ const allowMissingOrigin = pathname === SUPERVISOR_STATUS_PATH && req.method === "GET" && sameOrigin;
15641
+ if (!this.authorityAccepted(req, allowMissingOrigin)) {
15642
+ res.writeHead(403);
15643
+ res.end("Forbidden");
15644
+ return;
15645
+ }
15646
+ if (pathname === SUPERVISOR_ACCESS_CODE_PATH) {
15647
+ if (this.arrivedPublicly(req)) {
15648
+ res.writeHead(403);
15649
+ res.end("Forbidden");
15650
+ return;
15651
+ }
15652
+ if (req.method !== "POST") {
15653
+ res.writeHead(405, { allow: "POST" });
15654
+ res.end();
15655
+ return;
15656
+ }
15657
+ const link = this.issueAccessCode();
15658
+ const url = link && this.accessUrl(link.code);
15659
+ if (!link || !url) {
15660
+ responseJson(res, 409, { error: "this board has no public origin, so there is nothing to link to" });
15661
+ return;
15662
+ }
15663
+ responseJson(res, 200, { url, expiresAt: link.expiresAt });
15664
+ return;
15665
+ }
15666
+ if (pathname === SUPERVISOR_SESSIONS_PATH) {
15667
+ if (this.arrivedPublicly(req)) {
15668
+ res.writeHead(403);
15669
+ res.end("Forbidden");
15670
+ return;
15671
+ }
15672
+ if (!this.publicOrigin) {
15673
+ responseJson(res, 409, { error: "this board has no public origin, so nothing is signed in" });
15674
+ return;
15675
+ }
15676
+ if (req.method === "GET") {
15677
+ responseJson(res, 200, { sessions: this.access.sessions.list() });
15678
+ return;
15679
+ }
15680
+ if (req.method !== "POST") {
15681
+ res.writeHead(405, { allow: "GET, POST" });
15682
+ res.end();
15683
+ return;
15684
+ }
15685
+ const body = await readJsonBody(req);
15686
+ const id = typeof body?.id === "string" ? body.id : void 0;
15687
+ if (body?.all === true) {
15688
+ responseJson(res, 200, { signedOut: this.access.signOutAll() });
15689
+ return;
15690
+ }
15691
+ if (!id) {
15692
+ responseJson(res, 400, { error: "name a device id, or pass all: true" });
15693
+ return;
15694
+ }
15695
+ if (!this.access.sessions.revoke(id)) {
15696
+ responseJson(res, 404, { error: `no signed-in device has the id ${id}` });
15697
+ return;
15698
+ }
15699
+ responseJson(res, 200, { signedOut: 1 });
15700
+ return;
15701
+ }
15702
+ if (pathname === SUPERVISOR_SIGN_OUT_PATH) {
15703
+ if (req.method !== "POST") {
15704
+ res.writeHead(405, { allow: "POST" });
15705
+ res.end();
15706
+ return;
15707
+ }
15708
+ this.signOutThisDevice(req, res);
15709
+ return;
15710
+ }
15711
+ if (pathname === SUPERVISOR_STATUS_PATH && req.method === "GET") {
15712
+ responseJson(res, 200, {
15713
+ protocol: SUPERVISOR_CONTROL_PROTOCOL,
15714
+ ...this.status(),
15715
+ // Sent ONLY when true, so a loopback tab's payload is byte-identical to what it was. The web
15716
+ // app cannot tell on its own: the session cookie is HttpOnly, and the page is the same bundle
15717
+ // whichever way it was reached. It is a fact about THIS request, never about the board.
15718
+ ...this.carriesRemoteSession(req) ? { remoteSession: true } : {}
15719
+ });
15720
+ return;
15721
+ }
15722
+ if (pathname !== SUPERVISOR_RESTART_PATH && pathname !== SUPERVISOR_UPDATE_RESTART_PATH || req.method !== "POST") {
15723
+ res.writeHead(405, { allow: pathname === SUPERVISOR_STATUS_PATH ? "GET" : "POST" });
15724
+ res.end();
15725
+ return;
15726
+ }
15727
+ if (pathname === SUPERVISOR_UPDATE_RESTART_PATH && !this.options.updateRestart) {
15728
+ responseJson(res, 409, { protocol: SUPERVISOR_CONTROL_PROTOCOL, state: "failed", message: "Update & Restart is available only for a stable immutable Frizz artifact" });
15729
+ return;
15730
+ }
15731
+ if (pathname === SUPERVISOR_UPDATE_RESTART_PATH) {
15732
+ void this.runAction(this.options.updateRestart, "update");
15733
+ responseJson(res, 202, { protocol: SUPERVISOR_CONTROL_PROTOCOL, state: "restarting" });
15734
+ return;
15735
+ }
15736
+ const result = await this.runAction(this.options.restart, "restart");
15737
+ responseJson(res, result.state === "ready" ? 202 : 503, { protocol: SUPERVISOR_CONTROL_PROTOCOL, ...result });
15738
+ }
15739
+ handleLocalImage(req, res) {
15740
+ const allowMissingOrigin = req.headers.origin === void 0 && (req.headers["sec-fetch-site"] === "same-origin" || this.vouchesSameOrigin(req));
15741
+ if (!this.authorityAccepted(req, allowMissingOrigin)) {
15742
+ res.writeHead(403, { "content-type": "text/plain; charset=UTF-8" });
15743
+ res.end("Forbidden");
15744
+ return;
15745
+ }
15746
+ const url = new URL(req.url ?? "/", "http://frizz.invalid");
15747
+ const result = resolveLocalImage(url.searchParams.get("path") ?? void 0, req.headers.range);
15748
+ const origin = typeof req.headers.origin === "string" ? allowedLocalCorsOrigin(req.headers.origin, this.options.port, this.policy) : void 0;
15749
+ const sharedHeaders = {
15750
+ ...origin ? { "access-control-allow-origin": origin } : {},
15751
+ "access-control-expose-headers": "x-frizz-boot",
15752
+ vary: "Origin"
15753
+ };
15754
+ if (result.status === 416) {
15755
+ res.writeHead(416, { ...sharedHeaders, "content-range": `bytes */${result.size}`, "accept-ranges": "bytes" });
15756
+ res.end();
15757
+ return;
15758
+ }
15759
+ if (result.status === 400 || result.status === 404) {
15760
+ res.writeHead(result.status, { ...sharedHeaders, "content-type": "text/plain; charset=UTF-8" });
15761
+ res.end(String(result.status));
15762
+ return;
15763
+ }
15764
+ res.writeHead(result.status, { ...sharedHeaders, ...localImageHeaders(result) });
15765
+ const stream = req.method === "HEAD" ? null : localImageStream(result);
15766
+ if (!stream) {
15767
+ res.end();
15768
+ return;
15769
+ }
15770
+ pipeline(stream, res, () => void 0);
15771
+ }
15772
+ /**
15773
+ * Is this request arriving as the declared public origin rather than over loopback?
15774
+ *
15775
+ * The token gate keys on this and not on "is the port exposed", because the two are independent:
15776
+ * a tunnelled board stays bound to 127.0.0.1 while being reachable from the internet by name.
15777
+ */
15778
+ arrivedPublicly(req) {
15779
+ const declared = this.publicOrigin;
15780
+ if (!declared) return false;
15781
+ const host = parseLocalHost(req.headers.host, this.options.port, this.policy);
15782
+ return !!host && !LOOPBACK_HOSTNAMES.has(host.hostname);
15783
+ }
15784
+ /**
15785
+ * Bearer check for the public origin. Two ways in: the cookie the exchange below sets, or the
15786
+ * one-time `?frizz_token=` in the URL the operator was handed at launch.
15787
+ *
15788
+ * Compared with `timingSafeEqual` on equal-length buffers — a plain `===` on a secret leaks its
15789
+ * prefix to a patient attacker, and this secret is the only thing between the internet and a shell.
15790
+ */
15791
+ /**
15792
+ * Is this request carrying a session this process minted?
15793
+ *
15794
+ * Sessions only. A raw `?frizz_code=` is NOT accepted here — it is redeemed once by exchangeCode()
15795
+ * below and traded for a session, so a code that has already been spent cannot keep working just by
15796
+ * staying in somebody's URL bar.
15797
+ */
15798
+ sessionAccepted(req) {
15799
+ if (!this.publicOrigin) return true;
15800
+ return this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
15801
+ }
15802
+ /** Did this request arrive through the public origin carrying a live session? Loopback never does. */
15803
+ carriesRemoteSession(req) {
15804
+ return this.arrivedPublicly(req) && this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
15805
+ }
15806
+ /**
15807
+ * End the session this request carries, and nothing else — see SUPERVISOR_SIGN_OUT_PATH in
15808
+ * handleControl for why that makes it safe to reach from the tunnel.
15809
+ *
15810
+ * A loopback request is a no-op even if it somehow carries a cookie: the operator's own tab has no
15811
+ * remote session to end, and loopback is the place `--sign-out` already covers.
15812
+ */
15813
+ signOutThisDevice(req, res) {
15814
+ const reply = (status, body, clearCookie) => {
15815
+ res.writeHead(status, {
15816
+ "content-type": "application/json; charset=utf-8",
15817
+ "cache-control": "no-store",
15818
+ // Same attributes the exchange set, so the browser matches and drops that exact cookie.
15819
+ ...clearCookie ? { "set-cookie": `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` } : {}
15820
+ });
15821
+ res.end(JSON.stringify(body));
15822
+ };
15823
+ if (!this.arrivedPublicly(req)) {
15824
+ reply(200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "no-remote-session" }, false);
15825
+ return;
15826
+ }
15827
+ const outcome = this.access.signOutSession(readCookie(req.headers.cookie, SESSION_COOKIE));
15828
+ if (outcome.result === "revoked") {
15829
+ reply(200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "signed-out", id: outcome.id }, true);
15830
+ return;
15831
+ }
15832
+ if (outcome.result === "legacy") {
15833
+ reply(200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "cookie-cleared" }, true);
15834
+ return;
15835
+ }
15836
+ reply(401, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "no-remote-session" }, true);
15837
+ }
15838
+ /**
15839
+ * Trade `?frizz_code=…` for a cookie, then bounce to the same URL
15840
+ * without the secret, so it never lands in history, a Referer header, or a screenshot.
15841
+ */
15842
+ exchangeCode(req, res) {
15843
+ const url = new URL(req.url ?? "/", "http://frizz.invalid");
15844
+ const code = url.searchParams.get(ACCESS_CODE_PARAM);
15845
+ if (code === null) return false;
15846
+ const redeemed = this.access.redeem(code, describeDevice(req.headers["user-agent"]));
15847
+ if (!redeemed?.ok) {
15848
+ res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
15849
+ res.end(unauthorizedPage(redeemed?.reason));
15850
+ return true;
15851
+ }
15852
+ const cookie = `${SESSION_COOKIE}=${redeemed.session}`;
15853
+ const maxAge = Math.max(0, Math.floor((redeemed.expiresAt - Date.now()) / 1e3));
15854
+ url.searchParams.delete(ACCESS_CODE_PARAM);
15855
+ const target2 = `${url.pathname}${url.search}${url.hash}` || "/";
15856
+ res.writeHead(302, {
15857
+ location: target2,
15858
+ "set-cookie": `${cookie}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
15859
+ "cache-control": "no-store"
15860
+ });
15861
+ res.end();
15862
+ return true;
15863
+ }
15864
+ handle(req, res) {
15865
+ if (isControlRequest(req)) {
15866
+ void this.handleControl(req, res);
15867
+ return;
15868
+ }
15869
+ if (this.arrivedPublicly(req) && this.exchangeCode(req, res)) return;
15870
+ if (this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
15871
+ res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
15872
+ res.end(unauthorizedPage());
15873
+ return;
15874
+ }
15875
+ if (!this.authorityAccepted(req)) {
15876
+ const name = unlistedHostName(req.headers.host, this.options.port, this.policy);
15877
+ if (name) {
15878
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
15879
+ res.end(req.method === "HEAD" ? void 0 : unlistedHostPage(name));
15880
+ return;
15881
+ }
15882
+ res.writeHead(403, { "content-type": "text/plain; charset=UTF-8" });
15883
+ res.end("Forbidden");
15884
+ return;
15885
+ }
15886
+ if (isLocalImageRequest(req)) {
15887
+ this.handleLocalImage(req, res);
15888
+ return;
15889
+ }
15890
+ const childPort = this.options.childPort();
15891
+ if (!childPort) {
15892
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "2" });
15893
+ res.end(recoveryPage(req.url ?? "/"));
15894
+ return;
15895
+ }
15896
+ const upstream = requestHttp({
15897
+ host: "127.0.0.1",
15898
+ port: childPort,
15899
+ method: req.method,
15900
+ path: req.url,
15901
+ headers: proxyHeaders(req, childPort, this.vouchesSameOrigin(req))
15902
+ }, (upstreamResponse) => {
15903
+ res.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
15904
+ upstreamResponse.pipe(res);
15905
+ });
15906
+ upstream.once("error", () => {
15907
+ if (res.headersSent) return res.destroy();
15908
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
15909
+ res.end(recoveryPage(req.url ?? "/", "unreachable"));
15910
+ });
15911
+ req.pipe(upstream);
15912
+ }
15913
+ handleUpgrade(req, socket, head) {
15914
+ if (this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
15915
+ rejectWebSocketUpgrade(socket, 401, "Unauthorized");
15916
+ return;
15917
+ }
15918
+ if (!this.authorityAccepted(req, false)) {
15919
+ socket.destroy();
15920
+ return;
15921
+ }
15922
+ const childPort = this.options.childPort();
15923
+ if (!childPort) {
15924
+ socket.destroy();
15925
+ return;
15926
+ }
15927
+ const upstream = connect(childPort, "127.0.0.1");
15928
+ for (const half of [socket, upstream]) {
15929
+ this.upgradedSockets.add(half);
15930
+ half.once("close", () => this.upgradedSockets.delete(half));
15931
+ }
15932
+ upstream.once("connect", () => {
15933
+ const headers = proxyHeaders(req, childPort);
15934
+ headers.connection = req.headers.connection ?? "Upgrade";
15935
+ const lines = [`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`];
15936
+ for (const [name, value] of Object.entries(headers)) {
15937
+ if (value === void 0) continue;
15938
+ for (const entry of Array.isArray(value) ? value : [value]) lines.push(`${name}: ${entry}`);
15939
+ }
15940
+ upstream.write(`${lines.join("\r\n")}\r
15941
+ \r
15942
+ `);
15943
+ if (head.length > 0) upstream.write(head);
15944
+ socket.pipe(upstream).pipe(socket);
15945
+ });
15946
+ upstream.once("error", () => socket.destroy());
15947
+ socket.once("error", () => upstream.destroy());
15948
+ }
15949
+ };
15950
+ }
15951
+ });
15952
+
15953
+ // packages/server/src/frizz-paths.ts
15954
+ import { existsSync as existsSync2 } from "node:fs";
15955
+ import { homedir as homedir2, tmpdir } from "node:os";
15956
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve, sep as sep2 } from "node:path";
15957
+ function legacyFrizzRoot(home = homedir2()) {
15958
+ return join3(home, LEGACY_DIR_NAME);
15959
+ }
15960
+ function xdg(env, name) {
14713
15961
  const value = env[name];
14714
15962
  return value && (value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value)) ? value : void 0;
14715
15963
  }
@@ -14749,7 +15997,7 @@ function frizzPaths(options2 = {}) {
14749
15997
  const env = options2.env ?? process.env;
14750
15998
  const platform = options2.platform ?? process.platform;
14751
15999
  const home = options2.home ?? homedir2();
14752
- const exists = options2.exists ?? existsSync;
16000
+ const exists = options2.exists ?? existsSync2;
14753
16001
  const legacyRoot = legacyFrizzRoot(home);
14754
16002
  if (exists(legacyRoot)) {
14755
16003
  return { data: legacyRoot, state: legacyRoot, cache: legacyRoot, legacy: true };
@@ -18666,7 +19914,7 @@ var init_logging = __esm({
18666
19914
  // packages/server/src/process-generation.ts
18667
19915
  import { execFileSync } from "node:child_process";
18668
19916
  import { randomUUID } from "node:crypto";
18669
- import { readFileSync as readFileSync5 } from "node:fs";
19917
+ import { readFileSync as readFileSync4 } from "node:fs";
18670
19918
  import { join as join7 } from "node:path";
18671
19919
  function errorCode(error) {
18672
19920
  return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
@@ -18682,9 +19930,9 @@ function processAlive(pid) {
18682
19930
  }
18683
19931
  function linuxGeneration(pid) {
18684
19932
  try {
18685
- const bootId = readFileSync5("/proc/sys/kernel/random/boot_id", "utf8").trim().toLowerCase();
19933
+ const bootId = readFileSync4("/proc/sys/kernel/random/boot_id", "utf8").trim().toLowerCase();
18686
19934
  if (!/^[0-9a-f-]{36}$/u.test(bootId)) return null;
18687
- const stat = readFileSync5(`/proc/${pid}/stat`, "utf8").trim();
19935
+ const stat = readFileSync4(`/proc/${pid}/stat`, "utf8").trim();
18688
19936
  const suffixAt = stat.lastIndexOf(") ");
18689
19937
  if (suffixAt < 0) return null;
18690
19938
  const fields = stat.slice(suffixAt + 2).trim().split(/\s+/u);
@@ -18805,7 +20053,7 @@ import {
18805
20053
  fsyncSync,
18806
20054
  mkdirSync as mkdirSync5,
18807
20055
  openSync as openSync2,
18808
- readFileSync as readFileSync6,
20056
+ readFileSync as readFileSync5,
18809
20057
  realpathSync as realpathSync2,
18810
20058
  renameSync,
18811
20059
  rmSync as rmSync3,
@@ -18867,7 +20115,7 @@ function lockOwnerPath(lockPath) {
18867
20115
  }
18868
20116
  function readOwner(lockPath) {
18869
20117
  try {
18870
- const value = JSON.parse(readFileSync6(lockOwnerPath(lockPath), "utf8"));
20118
+ const value = JSON.parse(readFileSync5(lockOwnerPath(lockPath), "utf8"));
18871
20119
  if (value.version !== 1 || !Number.isInteger(value.pid) || value.pid <= 0 || value.processStart !== void 0 && (typeof value.processStart !== "string" || value.processStart.length > 256) || typeof value.token !== "string" || !PROJECT_ID_RE.test(value.token) || typeof value.at !== "string") return void 0;
18872
20120
  return value;
18873
20121
  } catch {
@@ -18876,7 +20124,7 @@ function readOwner(lockPath) {
18876
20124
  }
18877
20125
  function legacyOwnerPid(lockPath) {
18878
20126
  try {
18879
- const value = JSON.parse(readFileSync6(lockOwnerPath(lockPath), "utf8"));
20127
+ const value = JSON.parse(readFileSync5(lockOwnerPath(lockPath), "utf8"));
18880
20128
  return Number.isInteger(value.pid) && value.pid > 0 ? value.pid : void 0;
18881
20129
  } catch {
18882
20130
  return void 0;
@@ -19222,7 +20470,7 @@ import {
19222
20470
  fsyncSync as fsyncSync2,
19223
20471
  mkdirSync as mkdirSync6,
19224
20472
  openSync as openSync3,
19225
- readFileSync as readFileSync7,
20473
+ readFileSync as readFileSync6,
19226
20474
  renameSync as renameSync2,
19227
20475
  rmSync as rmSync4,
19228
20476
  statSync as statSync4,
@@ -19302,7 +20550,7 @@ function parseGeneration(value) {
19302
20550
  }
19303
20551
  function parseGuard(path) {
19304
20552
  try {
19305
- const value = JSON.parse(readFileSync7(path, "utf8"));
20553
+ const value = JSON.parse(readFileSync6(path, "utf8"));
19306
20554
  const generation = parseGeneration(value);
19307
20555
  if (value.version !== 1 || !generation || typeof value.token !== "string" || !UUID_RE.test(value.token) || !validText(value.at, 128)) return null;
19308
20556
  return { version: 1, token: value.token, at: value.at, ...generation };
@@ -19382,7 +20630,7 @@ function parseDelegates(value) {
19382
20630
  }
19383
20631
  function parseOwner(path) {
19384
20632
  try {
19385
- const value = JSON.parse(readFileSync7(path, "utf8"));
20633
+ const value = JSON.parse(readFileSync6(path, "utf8"));
19386
20634
  const generation = parseGeneration(value);
19387
20635
  if (value.version !== 1 && value.version !== OWNER_VERSION || !generation || typeof value.token !== "string" || !UUID_RE.test(value.token) || typeof value.projectId !== "string" || !UUID_RE.test(value.projectId) || !validText(value.projectDir) || !isAbsolute3(value.projectDir) || !validRole(value.role) || !validText(value.acquiredAt, 128) || !validText(value.updatedAt, 128)) return null;
19388
20636
  const delegates = value.version === 1 ? [] : parseDelegates(value.delegates);
@@ -19424,7 +20672,7 @@ function removeStatusesForToken(stateDir, token) {
19424
20672
  for (const name of ["dev-supervisor.lock", "server.lock"]) {
19425
20673
  const path = join9(stateDir, name);
19426
20674
  try {
19427
- const value = JSON.parse(readFileSync7(path, "utf8"));
20675
+ const value = JSON.parse(readFileSync6(path, "utf8"));
19428
20676
  if (value.ownerToken === token) quarantine(path, "stale");
19429
20677
  } catch {
19430
20678
  }
@@ -19638,7 +20886,7 @@ function writeProjectStatus(path, value) {
19638
20886
  }
19639
20887
  function removeProjectStatus(path, expected2) {
19640
20888
  try {
19641
- const value = JSON.parse(readFileSync7(path, "utf8"));
20889
+ const value = JSON.parse(readFileSync6(path, "utf8"));
19642
20890
  if (value.pid !== expected2.pid || value.processStart !== expected2.processStart || value.publisherToken !== expected2.publisherToken || value.ownerToken !== expected2.ownerToken) return false;
19643
20891
  return quarantine(path, "release");
19644
20892
  } catch {
@@ -19672,14 +20920,14 @@ var init_project_launch = __esm({
19672
20920
  });
19673
20921
 
19674
20922
  // packages/server/src/boot-progress.ts
19675
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
20923
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync7, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
19676
20924
  import { basename as basename3, dirname as dirname7, join as join10 } from "node:path";
19677
20925
  function bootProgressPath(stateDir) {
19678
20926
  return join10(stateDir, BOOT_PROGRESS_NAME);
19679
20927
  }
19680
20928
  function readBootProgress(stateDir) {
19681
20929
  try {
19682
- const value = JSON.parse(readFileSync8(bootProgressPath(stateDir), "utf8"));
20930
+ const value = JSON.parse(readFileSync7(bootProgressPath(stateDir), "utf8"));
19683
20931
  if (!Number.isInteger(value.pid) || !Number.isInteger(value.step) || typeof value.phase !== "string" || typeof value.at !== "string") return null;
19684
20932
  return value;
19685
20933
  } catch {
@@ -19698,7 +20946,7 @@ var init_boot_progress = __esm({
19698
20946
 
19699
20947
  // packages/server/src/project-root.ts
19700
20948
  import { createHash as createHash3, randomUUID as randomUUID4 } from "node:crypto";
19701
- 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";
20949
+ import { closeSync as closeSync3, existsSync as existsSync4, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
19702
20950
  import { homedir as homedir8 } from "node:os";
19703
20951
  import { dirname as dirname8, join as join11, parse, resolve as resolve3 } from "node:path";
19704
20952
  function projectIdPath(root) {
@@ -19707,7 +20955,7 @@ function projectIdPath(root) {
19707
20955
  function readProjectIdFile(root) {
19708
20956
  let raw;
19709
20957
  try {
19710
- raw = readFileSync9(projectIdPath(root), "utf8");
20958
+ raw = readFileSync8(projectIdPath(root), "utf8");
19711
20959
  } catch {
19712
20960
  return void 0;
19713
20961
  }
@@ -19721,7 +20969,7 @@ function writeProjectIdFile(root, id) {
19721
20969
  const dir = join11(root, FRIZZ_DIR);
19722
20970
  mkdirSync8(dir, { recursive: true });
19723
20971
  const ignore = join11(dir, SELF_IGNORE);
19724
- if (!existsSync3(ignore)) {
20972
+ if (!existsSync4(ignore)) {
19725
20973
  try {
19726
20974
  writeFileSync8(ignore, "*\n", { flag: "wx" });
19727
20975
  } catch {
@@ -19776,7 +21024,7 @@ function isNotAGitWorktree(error) {
19776
21024
  return /not a git repository/iu.test(stderr) || /must be run in a work ?tree/iu.test(stderr);
19777
21025
  }
19778
21026
  function hasAny(dir, names) {
19779
- return names.some((name) => existsSync3(join11(dir, name)));
21027
+ return names.some((name) => existsSync4(join11(dir, name)));
19780
21028
  }
19781
21029
  function isHomeDirectory(dir, home = homedir8()) {
19782
21030
  const canonical = (value) => {
@@ -19818,7 +21066,7 @@ function discoverProjectRoot(cwd = process.cwd(), home = homedir8()) {
19818
21066
  const filesystemRoot = parse(dir).root;
19819
21067
  for (let at = dir; ; at = dirname8(at)) {
19820
21068
  if (at === stop || at === filesystemRoot) break;
19821
- if (existsSync3(projectIdPath(at))) return at;
21069
+ if (existsSync4(projectIdPath(at))) return at;
19822
21070
  if (hasAny(at, REPO_MARKERS)) return at;
19823
21071
  if (hasAny(at, PROJECT_MARKERS)) return at;
19824
21072
  if (dirname8(at) === at) break;
@@ -19851,7 +21099,7 @@ var init_project_root = __esm({
19851
21099
  });
19852
21100
 
19853
21101
  // packages/server/src/project-registry.ts
19854
- 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";
21102
+ import { closeSync as closeSync4, existsSync as existsSync5, fsyncSync as fsyncSync4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync9, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
19855
21103
  import { homedir as homedir9 } from "node:os";
19856
21104
  import { basename as basename4, dirname as dirname9, join as join12 } from "node:path";
19857
21105
  function registryPath(home = homedir9()) {
@@ -19860,7 +21108,7 @@ function registryPath(home = homedir9()) {
19860
21108
  function readRegistry(home = homedir9()) {
19861
21109
  let raw;
19862
21110
  try {
19863
- raw = readFileSync10(registryPath(home), "utf8");
21111
+ raw = readFileSync9(registryPath(home), "utf8");
19864
21112
  } catch {
19865
21113
  return { ...EMPTY, projects: [] };
19866
21114
  }
@@ -19944,7 +21192,7 @@ function registerProject(input, home = homedir9()) {
19944
21192
  writeRegistry(registry, home);
19945
21193
  return { entry: byId, action: "reopened" };
19946
21194
  }
19947
- if (existsSync4(join12(byId.path, ".frizz", ".id"))) return { action: "duplicate" };
21195
+ if (existsSync5(join12(byId.path, ".frizz", ".id"))) return { action: "duplicate" };
19948
21196
  byId.path = input.dir;
19949
21197
  byId.lastOpenedAt = at;
19950
21198
  writeRegistry(registry, home);
@@ -19970,7 +21218,7 @@ function registerProject(input, home = homedir9()) {
19970
21218
  }
19971
21219
  function listProjects(home = homedir9()) {
19972
21220
  const byRecency = (a, b) => a.lastOpenedAt < b.lastOpenedAt ? 1 : a.lastOpenedAt > b.lastOpenedAt ? -1 : 0;
19973
- return readRegistry(home).projects.map((p) => ({ ...p, stale: !existsSync4(p.path) })).sort((a, b) => {
21221
+ return readRegistry(home).projects.map((p) => ({ ...p, stale: !existsSync5(p.path) })).sort((a, b) => {
19974
21222
  if (a.order === void 0 && b.order === void 0) return byRecency(a, b);
19975
21223
  if (a.order === void 0) return 1;
19976
21224
  if (b.order === void 0) return -1;
@@ -20032,7 +21280,7 @@ var init_project_registry = __esm({
20032
21280
 
20033
21281
  // src/production.ts
20034
21282
  init_local_origin();
20035
- import { readFileSync as readFileSync15 } from "node:fs";
21283
+ import { readFileSync as readFileSync14 } from "node:fs";
20036
21284
  import { homedir as homedir11 } from "node:os";
20037
21285
  import { fileURLToPath as fileURLToPath2 } from "node:url";
20038
21286
  import { join as join17 } from "node:path";
@@ -20487,268 +21735,29 @@ html:not([tabsintitlebar="true"]) #tabbrowser-tabs {
20487
21735
  ).unref();
20488
21736
  }
20489
21737
 
20490
- // packages/server/src/access-codes.ts
20491
- import { randomBytes, timingSafeEqual, createHmac } from "node:crypto";
20492
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20493
- import { dirname, join as join2 } from "node:path";
20494
- var CODE_BYTES = 16;
20495
- var SESSION_BYTES = 32;
20496
- var SESSION_ID_BYTES = 6;
20497
- var DEFAULT_CODE_TTL_MS = 5 * 6e4;
20498
- var DEFAULT_SESSION_TTL_MS = 365 * 24 * 60 * 6e4;
20499
- function memorySessionDirectory(seed = []) {
20500
- const records = new Map(seed.map((r) => [r.id, r]));
20501
- return {
20502
- record: (r) => void records.set(r.id, r),
20503
- isRevoked: (id) => records.get(id)?.revokedAt !== void 0,
20504
- // Reverse FIRST, then sort. Array.sort is stable, so two devices that redeemed in the same
20505
- // millisecond — which two taps on one page reliably are — keep newest-inserted-first instead of
20506
- // falling back to insertion order and reading as oldest first.
20507
- list: () => [...records.values()].reverse().sort((a, b) => b.createdAt - a.createdAt),
20508
- revoke(id) {
20509
- const found = records.get(id);
20510
- if (!found || found.revokedAt !== void 0) return false;
20511
- found.revokedAt = Date.now();
20512
- return true;
20513
- },
20514
- revokeAll() {
20515
- let n = 0;
20516
- for (const r of records.values()) if (r.revokedAt === void 0) {
20517
- r.revokedAt = Date.now();
20518
- n++;
20519
- }
20520
- return n;
20521
- }
20522
- };
20523
- }
20524
- function describeDevice(userAgent) {
20525
- const ua = userAgent ?? "";
20526
- if (!ua.trim()) return "unknown device";
20527
- 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";
20528
- 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" : "";
20529
- return browser ? `${browser} on ${os}` : os;
20530
- }
20531
- function defaultRandomToken(bytes) {
20532
- return randomBytes(bytes).toString("base64url");
20533
- }
20534
- function secretsMatch(a, b) {
20535
- const left = Buffer.from(a);
20536
- const right = Buffer.from(b);
20537
- return left.length === right.length && timingSafeEqual(left, right);
20538
- }
20539
- var AccessStore = class _AccessStore {
20540
- codes = /* @__PURE__ */ new Map();
20541
- options;
20542
- sessions;
20543
- /** Signing key for sessions. Persisted by the caller; a fresh one signs every device out. */
20544
- signingKey;
20545
- constructor(options2 = {}) {
20546
- this.signingKey = options2.signingKey ?? randomBytes(32);
20547
- this.sessions = options2.sessions ?? memorySessionDirectory();
20548
- this.options = {
20549
- codeTtlMs: options2.codeTtlMs ?? DEFAULT_CODE_TTL_MS,
20550
- sessionTtlMs: options2.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,
20551
- now: options2.now ?? Date.now,
20552
- randomToken: options2.randomToken ?? defaultRandomToken,
20553
- onConsumed: options2.onConsumed
20554
- };
20555
- }
20556
- /** Mint a code to show as a QR or a link. Minting does not invalidate codes already outstanding. */
20557
- issue() {
20558
- this.sweep();
20559
- const now = this.options.now();
20560
- const code = {
20561
- code: this.options.randomToken(CODE_BYTES),
20562
- createdAt: now,
20563
- expiresAt: now + this.options.codeTtlMs
20564
- };
20565
- this.codes.set(code.code, code);
20566
- return { code: code.code, createdAt: code.createdAt, expiresAt: code.expiresAt };
20567
- }
20568
- /**
20569
- * Trade a code for a session, at most once, ever.
20570
- *
20571
- * The consumed marker is set BEFORE anything else can observe success, and JS runs this method to
20572
- * completion without interleaving, so two browsers racing the same code cannot both win. That
20573
- * atomicity is what makes "single-use" true rather than aspirational — it is the first thing the
20574
- * tests attack.
20575
- */
20576
- redeem(code, device) {
20577
- if (!code) return { ok: false, reason: "unknown" };
20578
- const stored = this.codes.get(code);
20579
- if (!stored || !secretsMatch(stored.code, code)) return { ok: false, reason: "unknown" };
20580
- const now = this.options.now();
20581
- if (stored.consumedAt !== void 0) return { ok: false, reason: "already-used" };
20582
- if (now >= stored.expiresAt) {
20583
- this.sweep();
20584
- return { ok: false, reason: "expired" };
20585
- }
20586
- stored.consumedAt = now;
20587
- this.sweep();
20588
- this.options.onConsumed?.(code);
20589
- const minted = this.mintSession(now);
20590
- this.sessions.record({ id: minted.id, label: device ?? "unknown device", createdAt: now });
20591
- return { ok: true, session: minted.session, expiresAt: now + this.options.sessionTtlMs, id: minted.id };
20592
- }
20593
- /**
20594
- * A session is `<expiry>.<id>.<nonce>.<hmac>` — signed with a key the caller persists so it survives
20595
- * a restart, and self-describing enough that a stale cookie is rejected without any lookup.
20596
- *
20597
- * The `id` is what makes ONE device signable-out. It is short because an operator types it into
20598
- * `frizz --sign-out <id>`, and it is inside the signed payload so it cannot be swapped for another
20599
- * device's. Rotating the key still revokes everything at once; this is the scalpel beside that axe.
20600
- *
20601
- * A session minted before ids existed has a two-part payload and still verifies — it simply has no id
20602
- * to revoke individually. Upgrading must not sign every device out.
20603
- */
20604
- mintSession(now) {
20605
- const expiresAt = now + this.options.sessionTtlMs;
20606
- const id = this.options.randomToken(SESSION_ID_BYTES);
20607
- const nonce = this.options.randomToken(SESSION_BYTES);
20608
- const payload = `${expiresAt}.${id}.${nonce}`;
20609
- return { session: `${payload}.${this.sign(payload)}`, id };
20610
- }
20611
- /** The id inside a session payload, or null for a legacy two-part one. */
20612
- static idOf(payload) {
20613
- const parts = payload.split(".");
20614
- return parts.length >= 3 ? parts[1] ?? null : null;
20615
- }
20616
- sign(payload) {
20617
- return createHmac("sha256", this.signingKey).update(payload).digest("base64url");
20618
- }
20619
- /** Is this cookie a session signed by this board's key, and still in date? */
20620
- verifySession(session) {
20621
- if (!session) return false;
20622
- const cut = session.lastIndexOf(".");
20623
- if (cut <= 0) return false;
20624
- const payload = session.slice(0, cut);
20625
- const signature = session.slice(cut + 1);
20626
- if (!secretsMatch(signature, this.sign(payload))) return false;
20627
- const expiresAt = Number(payload.slice(0, payload.indexOf(".")));
20628
- if (!Number.isFinite(expiresAt) || this.options.now() >= expiresAt) return false;
20629
- const id = _AccessStore.idOf(payload);
20630
- return !(id && this.sessions.isRevoked(id));
20631
- }
20632
- /**
20633
- * Sign out the device HOLDING this session — the "Sign out this device" row, not `--sign-out`.
20634
- *
20635
- * The id comes from the session itself, and only after its signature verifies, so the one thing this
20636
- * can ever revoke is the credential the caller just proved it holds. There is deliberately no
20637
- * parameter naming any other id: signing out SOMEONE ELSE stays loopback-only, because a stolen
20638
- * session must not be able to evict the owner and keep the board.
20639
- *
20640
- * - `revoked`: the id is on the denylist now, so a surviving copy of this cookie is refused too.
20641
- * - `legacy`: a session minted before ids existed verifies but has no id to revoke on its own. The
20642
- * caller can still clear the cookie; rotating the key remains the way to kill a copy of it.
20643
- * - `no-session`: nothing valid was presented — absent, forged, expired, or already signed out.
20644
- */
20645
- signOutSession(session) {
20646
- if (!session || !this.verifySession(session)) return { result: "no-session" };
20647
- const id = _AccessStore.idOf(session.slice(0, session.lastIndexOf(".")));
20648
- if (!id) return { result: "legacy" };
20649
- if (!this.sessions.revoke(id)) {
20650
- this.sessions.record({ id, label: "unknown device", createdAt: this.options.now() });
20651
- this.sessions.revoke(id);
20652
- }
20653
- return this.sessions.isRevoked(id) ? { result: "revoked", id } : { result: "no-session" };
20654
- }
20655
- /**
20656
- * Drop codes past their expiry so a long-lived board does not accumulate them forever.
20657
- *
20658
- * A CONSUMED code is deliberately kept until that same expiry rather than deleted on use. Deleting it
20659
- * immediately would make a replay indistinguishable from a typo — both "unknown" — and the difference
20660
- * matters: "already-used" is the message that tells someone their link was used by somebody else.
20661
- * It costs one small entry for at most the code TTL.
20662
- */
20663
- sweep() {
20664
- const now = this.options.now();
20665
- for (const [key, stored] of this.codes) {
20666
- if (now >= stored.expiresAt) this.codes.delete(key);
20667
- }
20668
- }
20669
- /** Outstanding codes that could still be redeemed — neither consumed nor expired. */
20670
- outstanding() {
20671
- this.sweep();
20672
- return [...this.codes.values()].filter((stored) => stored.consumedAt === void 0).map(({ code, createdAt, expiresAt }) => ({ code, createdAt, expiresAt }));
20673
- }
20674
- };
20675
- function fileSessionDirectory(stateDir, now = Date.now) {
20676
- const path = join2(stateDir, "sessions.json");
20677
- let records = [];
20678
- try {
20679
- const parsed = JSON.parse(readFileSync(path, "utf8"));
20680
- if (Array.isArray(parsed)) {
20681
- records = parsed.filter(
20682
- (r) => !!r && typeof r.id === "string" && typeof r.createdAt === "number"
20683
- );
20684
- }
20685
- } catch {
20686
- }
20687
- const inner = memorySessionDirectory(records);
20688
- const flush = () => {
20689
- try {
20690
- mkdirSync(dirname(path), { recursive: true });
20691
- writeFileSync(path, JSON.stringify(inner.list()), { mode: 384 });
20692
- } catch (error) {
20693
- if (process.env.FRIZZ_DEBUG_SESSIONS) console.error("[sessions] flush failed:", error);
20694
- }
20695
- };
20696
- return {
20697
- record(r) {
20698
- inner.record(r);
20699
- flush();
20700
- },
20701
- isRevoked: (id) => inner.isRevoked(id),
20702
- list: () => inner.list(),
20703
- revoke(id) {
20704
- const ok = inner.revoke(id);
20705
- if (ok) flush();
20706
- return ok;
20707
- },
20708
- revokeAll() {
20709
- const n = inner.revokeAll();
20710
- if (n > 0) flush();
20711
- return n;
20712
- }
20713
- };
20714
- }
20715
- function loadOrCreateSessionKey(stateDir) {
20716
- const path = join2(stateDir, "session-key");
20717
- try {
20718
- const existing = readFileSync(path);
20719
- if (existing.byteLength >= 32) return existing;
20720
- } catch {
20721
- }
20722
- const key = randomBytes(32);
20723
- mkdirSync(dirname(path), { recursive: true });
20724
- writeFileSync(path, key, { mode: 384 });
20725
- try {
20726
- chmodSync(path, 384);
20727
- } catch {
20728
- }
20729
- return key;
20730
- }
20731
-
20732
- // node_modules/.pnpm/qrcode-generator@2.0.4/node_modules/qrcode-generator/dist/qrcode.mjs
20733
- var qrcode = function(typeNumber, errorCorrectionLevel) {
20734
- const PAD0 = 236;
20735
- const PAD1 = 17;
20736
- let _typeNumber = typeNumber;
20737
- const _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
20738
- let _modules = null;
20739
- let _moduleCount = 0;
20740
- let _dataCache = null;
20741
- const _dataList = [];
20742
- const _this = {};
20743
- const makeImpl = function(test, maskPattern) {
20744
- _moduleCount = _typeNumber * 4 + 17;
20745
- _modules = (function(moduleCount) {
20746
- const modules = new Array(moduleCount);
20747
- for (let row = 0; row < moduleCount; row += 1) {
20748
- modules[row] = new Array(moduleCount);
20749
- for (let col = 0; col < moduleCount; col += 1) {
20750
- modules[row][col] = null;
20751
- }
21738
+ // src/production.ts
21739
+ init_access_codes();
21740
+
21741
+ // node_modules/.pnpm/qrcode-generator@2.0.4/node_modules/qrcode-generator/dist/qrcode.mjs
21742
+ var qrcode = function(typeNumber, errorCorrectionLevel) {
21743
+ const PAD0 = 236;
21744
+ const PAD1 = 17;
21745
+ let _typeNumber = typeNumber;
21746
+ const _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
21747
+ let _modules = null;
21748
+ let _moduleCount = 0;
21749
+ let _dataCache = null;
21750
+ const _dataList = [];
21751
+ const _this = {};
21752
+ const makeImpl = function(test, maskPattern) {
21753
+ _moduleCount = _typeNumber * 4 + 17;
21754
+ _modules = (function(moduleCount) {
21755
+ const modules = new Array(moduleCount);
21756
+ for (let row = 0; row < moduleCount; row += 1) {
21757
+ modules[row] = new Array(moduleCount);
21758
+ for (let col = 0; col < moduleCount; col += 1) {
21759
+ modules[row][col] = null;
21760
+ }
20752
21761
  }
20753
21762
  return modules;
20754
21763
  })(_moduleCount);
@@ -22305,982 +23314,168 @@ var gifImage = function(width, height) {
22305
23314
  s = s + c;
22306
23315
  } else {
22307
23316
  bitOut.write(table.indexOf(s), bitLength);
22308
- if (table.size() < 4095) {
22309
- if (table.size() == 1 << bitLength) {
22310
- bitLength += 1;
22311
- }
22312
- table.add(s + c);
22313
- }
22314
- s = c;
22315
- }
22316
- }
22317
- bitOut.write(table.indexOf(s), bitLength);
22318
- bitOut.write(endCode, bitLength);
22319
- bitOut.flush();
22320
- return byteOut.toByteArray();
22321
- };
22322
- const lzwTable = function() {
22323
- const _map = {};
22324
- let _size = 0;
22325
- const _this2 = {};
22326
- _this2.add = function(key) {
22327
- if (_this2.contains(key)) {
22328
- throw "dup key:" + key;
22329
- }
22330
- _map[key] = _size;
22331
- _size += 1;
22332
- };
22333
- _this2.size = function() {
22334
- return _size;
22335
- };
22336
- _this2.indexOf = function(key) {
22337
- return _map[key];
22338
- };
22339
- _this2.contains = function(key) {
22340
- return typeof _map[key] != "undefined";
22341
- };
22342
- return _this2;
22343
- };
22344
- return _this;
22345
- };
22346
- var createDataURL = function(width, height, getPixel) {
22347
- const gif = gifImage(width, height);
22348
- for (let y = 0; y < height; y += 1) {
22349
- for (let x = 0; x < width; x += 1) {
22350
- gif.setPixel(x, y, getPixel(x, y));
22351
- }
22352
- }
22353
- const b = byteArrayOutputStream();
22354
- gif.write(b);
22355
- const base64 = base64EncodeOutputStream();
22356
- const bytes = b.toByteArray();
22357
- for (let i = 0; i < bytes.length; i += 1) {
22358
- base64.writeByte(bytes[i]);
22359
- }
22360
- base64.flush();
22361
- return "data:image/gif;base64," + base64;
22362
- };
22363
- var qrcode_default = qrcode;
22364
- var stringToBytes = qrcode.stringToBytes;
22365
-
22366
- // packages/server/src/qr.ts
22367
- var DARK = 232;
22368
- var LIGHT = 231;
22369
- var fg = (dark) => `\x1B[38;5;${dark ? DARK : LIGHT}m`;
22370
- var bg = (dark) => `\x1B[48;5;${dark ? DARK : LIGHT}m`;
22371
- var RESET = "\x1B[0m";
22372
- var UPPER_HALF = "\u2580";
22373
- var LOWER_HALF = "\u2584";
22374
- var QUIET_ZONE = 4;
22375
- var BLOCK_DRAWING_PROGRAMS = /* @__PURE__ */ new Set(["iTerm.app", "WezTerm", "ghostty", "WarpTerminal", "vscode"]);
22376
- var BLOCK_DRAWING_TERMS = /* @__PURE__ */ new Set(["xterm-kitty", "xterm-ghostty", "alacritty", "wezterm"]);
22377
- function drawsBlockElements(env = process.env) {
22378
- const term = env.TERM ?? "";
22379
- if (env.TMUX || env.STY || env.TERM_PROGRAM === "tmux" || term.startsWith("screen") || term.startsWith("tmux")) return false;
22380
- if (BLOCK_DRAWING_PROGRAMS.has(env.TERM_PROGRAM ?? "")) return true;
22381
- if (BLOCK_DRAWING_TERMS.has(term)) return true;
22382
- if (env.LC_TERMINAL === "iTerm2") return true;
22383
- return Boolean(
22384
- env.KITTY_WINDOW_ID || env.ALACRITTY_WINDOW_ID || env.WEZTERM_PANE || env.GHOSTTY_RESOURCES_DIR || env.WT_SESSION || env.VTE_VERSION
22385
- );
22386
- }
22387
- function encode(value, errorCorrection) {
22388
- const code = qrcode_default(0, errorCorrection);
22389
- code.addData(value);
22390
- code.make();
22391
- const modules = code.getModuleCount();
22392
- const size = modules + QUIET_ZONE * 2;
22393
- return {
22394
- size,
22395
- at: (x, y) => {
22396
- const mx = x - QUIET_ZONE;
22397
- const my = y - QUIET_ZONE;
22398
- if (mx < 0 || my < 0 || mx >= modules || my >= modules) return false;
22399
- return code.isDark(my, mx);
22400
- }
22401
- };
22402
- }
22403
- function sizeOf(size, style) {
22404
- return style === "paint" ? { columns: size * 2, rows: size } : { columns: size, rows: Math.ceil(size / 2) };
22405
- }
22406
- function qrSize(value, style = "half", errorCorrection = "M") {
22407
- return sizeOf(encode(value, errorCorrection).size, style);
22408
- }
22409
- function qrStyleFor(value, options2 = {}) {
22410
- const { area } = options2;
22411
- if (!area || drawsBlockElements(options2.env)) return "half";
22412
- const paint = qrSize(value, "paint", options2.errorCorrection);
22413
- return paint.columns <= area.columns && paint.rows <= area.rows ? "paint" : "half";
22414
- }
22415
- function qrAreaOf(stream, frame) {
22416
- if (!stream.columns || !stream.rows) return void 0;
22417
- return { columns: stream.columns - frame.indent, rows: stream.rows - frame.rows };
22418
- }
22419
- function halfLines(size, at, plain) {
22420
- const lines = [];
22421
- for (let y = 0; y < size; y += 2) {
22422
- let line = "";
22423
- for (let x = 0; x < size; x++) {
22424
- const top = at(x, y);
22425
- const bottom = y + 1 < size ? at(x, y + 1) : false;
22426
- if (plain) {
22427
- line += top && bottom ? "#" : top ? "^" : bottom ? "v" : " ";
22428
- continue;
22429
- }
22430
- if (top === bottom) {
22431
- line += `${bg(top)} `;
22432
- continue;
22433
- }
22434
- const above = y > 0 ? at(x, y - 1) : false;
22435
- const below = y + 2 < size ? at(x, y + 2) : false;
22436
- line += below === bottom && above !== top ? `${fg(top)}${bg(bottom)}${UPPER_HALF}` : `${fg(bottom)}${bg(top)}${LOWER_HALF}`;
22437
- }
22438
- lines.push(plain ? line : `${line}${RESET}`);
22439
- }
22440
- return lines;
22441
- }
22442
- function paintLines(size, at, plain) {
22443
- const lines = [];
22444
- for (let y = 0; y < size; y++) {
22445
- let line = "";
22446
- let last;
22447
- for (let x = 0; x < size; x++) {
22448
- const dark = at(x, y);
22449
- if (plain) line += dark ? "##" : " ";
22450
- else {
22451
- if (dark !== last) line += bg(dark);
22452
- line += " ";
22453
- }
22454
- last = dark;
22455
- }
22456
- lines.push(plain ? line : `${line}${RESET}`);
22457
- }
22458
- return lines;
22459
- }
22460
- function renderQrLines(value, options2 = {}) {
22461
- if (!value) throw new Error("renderQr requires a value");
22462
- const errorCorrection = options2.errorCorrection ?? "M";
22463
- const { size, at } = encode(value, errorCorrection);
22464
- const style = options2.style ?? qrStyleFor(value, { area: options2.area, env: options2.env, errorCorrection });
22465
- return style === "paint" ? paintLines(size, at, options2.plain ?? false) : halfLines(size, at, options2.plain ?? false);
22466
- }
22467
-
22468
- // packages/server/src/restart-supervisor.ts
22469
- import { request as requestHttp, createServer } from "node:http";
22470
- import { connect } from "node:net";
22471
- init_local_origin();
22472
- init_local_image();
22473
-
22474
- // packages/server/src/supervisor-pages.ts
22475
- function escapeHtml(value) {
22476
- return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
22477
- }
22478
- function retryTarget(url) {
22479
- return url.startsWith("/") && !url.startsWith("//") ? url : "/";
22480
- }
22481
- var BASE_STYLE = `
22482
- *,*::before,*::after{box-sizing:border-box}
22483
- :root{
22484
- color-scheme:dark;
22485
- --bg:#0d0e10;--panel:#131519;--panel-2:#181b20;
22486
- --border:#26282d;--border-strong:#33363c;--control-border:#26282d;--control-strong:#33363c;
22487
- --fg:#e6e7e9;--muted:#8b8f96;--accent:#e8b923;--danger:#fca5a5;
22488
- --focus-border:color-mix(in srgb,var(--accent) 55%,transparent);--focus-ring:color-mix(in srgb,var(--accent) 22%,transparent);
22489
- --sans:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
22490
- --mono:ui-monospace,"JetBrains Mono","SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
22491
- --ease:cubic-bezier(0,0,.2,1);
22492
- }
22493
- :root[data-theme=light]{
22494
- color-scheme:light;
22495
- --bg:#f7f7f7;--panel:#fff;--panel-2:#fafafa;
22496
- --border:#dedede;--border-strong:#cecece;--control-border:#858585;--control-strong:#7d7d7d;
22497
- --fg:#292929;--muted:#606060;--accent:#416896;--danger:#cf222e;
22498
- --focus-border:#416896;--focus-ring:#416896;
22499
- }
22500
- @media(prefers-color-scheme:light){
22501
- :root:not([data-theme]){
22502
- color-scheme:light;
22503
- --bg:#f7f7f7;--panel:#fff;--panel-2:#fafafa;
22504
- --border:#dedede;--border-strong:#cecece;--control-border:#858585;--control-strong:#7d7d7d;
22505
- --fg:#292929;--muted:#606060;--accent:#416896;--danger:#cf222e;
22506
- --focus-border:#416896;--focus-ring:#416896;
22507
- }
22508
- }
22509
- html,body{background:var(--bg)}
22510
- body{
22511
- margin:0;min-height:100vh;min-height:100dvh;
22512
- display:flex;align-items:center;justify-content:center;padding:24px;
22513
- color:var(--fg);font-family:var(--sans);font-size:12.5px;line-height:1.55;
22514
- -webkit-font-smoothing:antialiased;
22515
- }
22516
- main{width:100%;max-width:28rem;padding:18px;border:1px solid var(--border-strong);border-radius:12px;background:var(--panel-2)}
22517
- h1{margin:0;display:flex;align-items:baseline;gap:7px;font-size:14px;font-weight:600;letter-spacing:-.01em;line-height:1.4}
22518
- p{margin:0;text-wrap:pretty}
22519
- .detail{margin-top:7px;color:color-mix(in srgb,var(--fg) 88%,transparent)}
22520
- .note{margin-top:12px;padding-top:11px;border-top:1px solid var(--border);color:var(--muted);font-size:11.5px;line-height:1.6}
22521
- code{font-family:var(--mono);font-size:.92em;padding:.1em .35em;border-radius:4px;background:var(--panel);border:1px solid var(--border);white-space:nowrap}
22522
- [hidden]{display:none!important}
22523
- `.trim();
22524
- var RECOVERY_STYLE = `
22525
- .actions{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px}
22526
- /* ErrorBoundary's ACTION_CLASS, one size up for a page that is alone in a viewport. */
22527
- .btn{
22528
- appearance:none;display:inline-flex;align-items:center;
22529
- border:1px solid var(--control-border);border-radius:6px;background:transparent;
22530
- padding:5px 10px;font:inherit;font-size:11.5px;color:color-mix(in srgb,var(--fg) 90%,transparent);
22531
- cursor:pointer;text-decoration:none;
22532
- transition:background-color 120ms var(--ease),border-color 120ms var(--ease),color 120ms var(--ease);
22533
- }
22534
- .btn:hover{background:var(--panel);border-color:var(--control-strong);color:var(--fg)}
22535
- .btn:focus-visible{outline:none;border-color:var(--focus-border);box-shadow:0 0 0 2px var(--focus-ring)}
22536
- .btn:disabled{opacity:.5;pointer-events:none}
22537
- /* min-height holds the slot open while it is LIVE, so a changing status line never nudges the card;
22538
- :empty collapses it when there is nothing live to say, so a halted card has no trailing gutter. */
22539
- .probe{margin-top:12px;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--muted);min-height:1.5em}
22540
- .probe:empty{margin-top:0;min-height:0}
22541
- /* RestartFailureNotice's block, at this altitude: a supervisor \`message\` is raw build output, and it
22542
- reads as what it is \u2014 a terminal excerpt \u2014 only inside a scrolling mono box. */
22543
- .log{
22544
- margin-top:10px;max-height:16rem;overflow:auto;white-space:pre-wrap;overflow-wrap:break-word;
22545
- border:1px solid var(--border);border-radius:6px;background:var(--panel);padding:8px 10px;
22546
- font-family:var(--mono);font-size:11px;line-height:1.6;color:color-mix(in srgb,var(--fg) 70%,transparent);
22547
- }
22548
-
22549
- /* THE LIVE DOT, and it is the app's own \`.frizz-live-dot\` \u2014 same 6px, same 28%/14% halo, same 1.25s
22550
- cadence \u2014 because it means the same thing here that it means there: work is in flight.
22551
-
22552
- Alignment is an INK problem. A baseline-aligned flex item with no text of its own contributes its
22553
- BORDER-BOX BOTTOM as the baseline, so the dot's ink centre lands 3px above it while the cap band's
22554
- centre sits 0.5cap above it. \`cap\` is the resolved font's own cap height, so the correction holds
22555
- at any size, with nothing to re-measure when the type scale moves. The em spelling is the fallback for a browser without the unit (.35em ~= 0.5 x a
22556
- 0.7em cap) and is overwritten wherever \`cap\` parses. */
22557
- .dot{
22558
- width:6px;height:6px;flex:0 0 auto;border-radius:9999px;background:var(--accent);
22559
- box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 28%,transparent);
22560
- animation:frizz-live-pulse 1.25s ease-in-out infinite;
22561
- transform:translateY(calc(3px - .35em));
22562
- transform:translateY(calc(3px - .5cap));
22563
- }
22564
- .dot[data-state=idle]{animation:none;background:var(--muted);box-shadow:none}
22565
- .dot[data-state=bad]{animation:none;background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 28%,transparent)}
22566
- @keyframes frizz-live-pulse{50%{opacity:.62;box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 14%,transparent)}}
22567
- @media (prefers-reduced-motion:reduce){
22568
- .dot{animation:none;background:transparent;border:2px solid var(--accent);box-shadow:none}
22569
- .dot[data-state=idle]{border-color:var(--muted)}
22570
- .dot[data-state=bad]{border-color:var(--danger);background:transparent}
22571
- }
22572
-
22573
- /* The app's \`.shimmer-text\`: a highlight sweeping muted text is how this product says "working", and
22574
- the probe line is the only thing on the page that is. */
22575
- .probe[data-live=yes]{
22576
- color:transparent;
22577
- background:linear-gradient(90deg,var(--muted) 0%,var(--muted) 35%,var(--fg) 50%,var(--muted) 65%,var(--muted) 100%) 0 0/200% 100%;
22578
- -webkit-background-clip:text;background-clip:text;
22579
- animation:frizz-shimmer 2.2s linear infinite;
22580
- }
22581
- @keyframes frizz-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}
22582
- @media (prefers-reduced-motion:reduce){
22583
- .probe[data-live=yes]{animation:none;background:none;color:var(--muted)}
22584
- }
22585
- `.trim();
22586
- var THEME_SCRIPT = `var p;try{p=localStorage.getItem("frizz-theme")}catch{}var d=false;try{d=matchMedia&&matchMedia("(prefers-color-scheme: dark)").matches}catch{}var t=p==="dark"||p==="light"?p:d?"dark":"light";document.documentElement.dataset.theme=t;document.documentElement.style.colorScheme=t;document.querySelector('meta[name="theme-color"]').content=t==="dark"?"#0d0e10":"#f7f7f7"`;
22587
- var RECOVERY_SCRIPT = `
22588
- (function(){
22589
- var target=document.body.dataset.target||"/"
22590
- var heading=document.getElementById("heading")
22591
- var detail=document.getElementById("detail")
22592
- var probe=document.getElementById("probe")
22593
- var log=document.getElementById("log")
22594
- var dot=document.getElementById("dot")
22595
- var restart=document.getElementById("restart")
22596
- var BUDGET=120000,MIN=600,MAX=4000,GROWTH=1.4
22597
- var delay=MIN,deadline=Date.now()+BUDGET,timer=0,stopped=false,busy=false,told=""
22598
-
22599
- function say(text,live){probe.textContent=text;probe.setAttribute("data-live",live?"yes":"no")}
22600
- function halt(text,bad){stopped=true;clearTimeout(timer);dot.setAttribute("data-state",bad?"bad":"idle");say(text,false)}
22601
- function showLog(message){if(message){log.textContent=message;log.hidden=false}else{log.hidden=true}}
22602
- function resume(){stopped=false;busy=false;delay=MIN;deadline=Date.now()+BUDGET;dot.removeAttribute("data-state");clearTimeout(timer);timer=setTimeout(tick,0)}
22603
-
22604
- // 503 is this proxy's own "no child". Every other answer \u2014 200, 404, even a 500 from the app \u2014 means
22605
- // something is listening again, and the reader's own URL is the right thing to ask.
22606
- function alive(){
22607
- return fetch(target,{method:"HEAD",cache:"no-store",credentials:"same-origin"})
22608
- .then(function(r){return r.status!==503}).catch(function(){return false})
22609
- }
22610
- function readStatus(){
22611
- return fetch("/_frizz/control/status",{cache:"no-store",headers:{"cache-control":"no-store"}})
22612
- .then(function(r){return r.ok?r.json():null})
22613
- .then(function(b){return b&&b.protocol===1?b:null}).catch(function(){return null})
22614
- }
22615
-
22616
- // An unreachable launcher leaves the server-rendered copy alone: it is already right for the case
22617
- // that rendered it, and guessing over it would be worse than saying nothing.
22618
- function narrate(status){
22619
- if(!status||status.state===told)return
22620
- told=status.state
22621
- if(status.state==="failed"){
22622
- heading.textContent="Frizz did not restart"
22623
- // Not a second copy of the note below it \u2014 a lead-in to the log, which is the next thing down.
22624
- detail.textContent="The launcher could not bring the application server back, and reported this:"
22625
- showLog(status.message||"The launcher gave no reason.")
22626
- halt("",true)
22627
- return
22628
- }
22629
- showLog("")
22630
- if(status.state==="restarting"){
22631
- heading.textContent="Frizz is restarting"
22632
- detail.textContent="The application server is coming back up."
22633
- }
22634
- }
22635
-
22636
- function tick(){
22637
- if(stopped||busy)return
22638
- // A hidden tab neither polls nor spends its budget; visibilitychange starts the loop again.
22639
- if(document.hidden)return
22640
- alive().then(function(up){
22641
- if(up){say("Frizz is back. Reloading.",true);location.replace(target);return}
22642
- return readStatus().then(function(status){
22643
- narrate(status)
22644
- if(stopped)return
22645
- if(Date.now()>deadline){halt("No answer after 2m. This page stopped checking.",false);return}
22646
- delay=Math.min(MAX,delay*GROWTH)
22647
- say("Waiting for Frizz",true)
22648
- timer=setTimeout(tick,delay)
22649
- })
22650
- })
22651
- }
22652
-
22653
- document.addEventListener("visibilitychange",function(){
22654
- if(document.hidden||stopped||busy)return
22655
- resume()
22656
- })
22657
-
22658
- restart.addEventListener("click",function(){
22659
- if(busy)return
22660
- busy=true;stopped=false;told="";clearTimeout(timer)
22661
- restart.disabled=true;restart.textContent="Restarting"
22662
- dot.removeAttribute("data-state");showLog("");say("Restarting Frizz",true)
22663
- fetch("/_frizz/control/restart",{method:"POST",headers:{"cache-control":"no-store"}})
22664
- .then(function(r){return r.json().catch(function(){return null}).then(function(b){return{ok:r.ok,status:r.status,body:b}})})
22665
- .catch(function(){return{ok:false,status:0,body:null}})
22666
- .then(function(r){
22667
- restart.disabled=false;restart.textContent="Restart Frizz";busy=false
22668
- if(!r.ok){
22669
- heading.textContent="Frizz did not restart"
22670
- detail.textContent="The launcher could not bring the application server back, and reported this:"
22671
- showLog((r.body&&r.body.message)||("The launcher refused the restart ("+(r.status||"no response")+")."))
22672
- halt("",true)
22673
- return
22674
- }
22675
- // Accepted. The probe, not this response, decides whether the child is actually back.
22676
- resume()
22677
- })
22678
- })
22679
-
22680
- say("Waiting for Frizz",true)
22681
- tick()
22682
- })()
22683
- `.trim();
22684
- function documentShell(options2) {
22685
- return `<!doctype html><html lang="en" data-font="sans"><head><meta charset="utf-8"><title>${options2.title}</title><meta name="viewport" content="width=device-width,initial-scale=1">` + (options2.appearance ? `<meta name="color-scheme" content="dark light"><meta name="theme-color" content="#0d0e10">` : `<meta name="color-scheme" content="dark light"><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0d0e10"><meta name="theme-color" media="(prefers-color-scheme: light)" content="#f7f7f7">`) + `<style>${options2.style ? `${BASE_STYLE}
22686
- ${options2.style}` : BASE_STYLE}</style>` + (options2.appearance ? `<script>${THEME_SCRIPT}</script>` : "") + `</head><body${options2.bodyAttributes ? ` ${options2.bodyAttributes}` : ""}><main>${options2.body}</main>` + (options2.script ? `<script>${options2.script}</script>` : "") + `</body></html>`;
22687
- }
22688
- var RECOVERY_COPY = {
22689
- starting: {
22690
- title: "Restarting | Frizz",
22691
- heading: "Frizz is restarting",
22692
- detail: "The application server is not listening yet. This page reloads as soon as it answers."
22693
- },
22694
- unreachable: {
22695
- title: "Not responding | Frizz",
22696
- heading: "Frizz is not responding",
22697
- detail: "The application server accepted this request and then dropped it. Restarting it usually clears this."
22698
- }
22699
- };
22700
- var THREADS_NOTE = "Threads already running are not affected. Each runs as its own detached process, and Frizz reattaches to it on restart.";
22701
- function recoveryPage(url, variant = "starting") {
22702
- const copy = RECOVERY_COPY[variant];
22703
- const target2 = escapeHtml(retryTarget(url));
22704
- return documentShell({
22705
- title: copy.title,
22706
- style: RECOVERY_STYLE,
22707
- appearance: true,
22708
- bodyAttributes: `data-target="${target2}"`,
22709
- body: `<h1><span class="dot" id="dot" aria-hidden="true"></span><span id="heading">${copy.heading}</span></h1><p class="detail" id="detail">${copy.detail}</p><pre class="log" id="log" hidden></pre><p class="note">${THREADS_NOTE}</p><div class="actions"><a class="btn" href="${target2}">Try again</a><button class="btn" type="button" id="restart">Restart Frizz</button></div><p class="probe" id="probe" aria-live="polite"></p>`,
22710
- script: RECOVERY_SCRIPT
22711
- });
22712
- }
22713
- function unauthorizedPage(reason) {
22714
- const detail = reason === "already-used" ? "That link has already been used. Generate a fresh one." : reason === "expired" ? "That link expired. Generate a fresh one." : "This address needs a current access link.";
22715
- return documentShell({
22716
- title: "Unauthorized",
22717
- body: `<h1>Unauthorized</h1><p class="detail">${detail}</p>`
22718
- });
22719
- }
22720
- function unlistedHostPage(name) {
22721
- const shown = escapeHtml(name);
22722
- return documentShell({
22723
- title: "Not served by this name",
22724
- appearance: true,
22725
- body: `<h1>Not served by this name</h1><p class="detail">Frizz does not answer to <code>${shown}</code>. A name a browser has to resolve is how DNS rebinding makes another site's page count as this board, so an exposed board accepts only its own hostname and the names its operator lists.</p><p class="note">Open the board by its IP address, or relaunch with <code>--allowed-host ${shown}</code> (or <code>FRIZZ_ALLOWED_HOSTS=${shown}</code>) to accept this one.</p>`
22726
- });
22727
- }
22728
-
22729
- // packages/server/src/restart-supervisor.ts
22730
- init_src();
22731
- var SUPERVISOR_CONTROL_PREFIX = "/_frizz/control";
22732
- var SUPERVISOR_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/restart`;
22733
- var SUPERVISOR_UPDATE_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/update-restart`;
22734
- var SUPERVISOR_STATUS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/status`;
22735
- var SUPERVISOR_ACCESS_CODE_PATH = `${SUPERVISOR_CONTROL_PREFIX}/access-code`;
22736
- var SUPERVISOR_SESSIONS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/sessions`;
22737
- var SUPERVISOR_SIGN_OUT_PATH = `${SUPERVISOR_CONTROL_PREFIX}/sign-out`;
22738
- var SUPERVISOR_CONTROL_PROTOCOL = 1;
22739
- function responseJson(res, status, value) {
22740
- res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
22741
- res.end(JSON.stringify(value));
22742
- }
22743
- function proxyHeaders(req, childPort, vouchSameOrigin = false) {
22744
- const headers = { ...req.headers };
22745
- headers.host = `127.0.0.1:${childPort}`;
22746
- if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${childPort}`;
22747
- for (const name of FORWARDED_HEADERS) delete headers[name];
22748
- if (vouchSameOrigin) headers["sec-fetch-site"] = "same-origin";
22749
- delete headers.connection;
22750
- return headers;
22751
- }
22752
- var ACCESS_CODE_PARAM = "frizz_code";
22753
- var SESSION_COOKIE = "frizz_session";
22754
- var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
22755
- function readCookie(header, name) {
22756
- if (!header) return void 0;
22757
- for (const pair of header.split(";")) {
22758
- const eq = pair.indexOf("=");
22759
- if (eq === -1) continue;
22760
- if (pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim();
22761
- }
22762
- return void 0;
22763
- }
22764
- function isControlRequest(req) {
22765
- const url = new URL(req.url ?? "/", "http://frizz.invalid");
22766
- return url.pathname === SUPERVISOR_RESTART_PATH || url.pathname === SUPERVISOR_UPDATE_RESTART_PATH || url.pathname === SUPERVISOR_STATUS_PATH || url.pathname === SUPERVISOR_ACCESS_CODE_PATH || url.pathname === SUPERVISOR_SESSIONS_PATH || url.pathname === SUPERVISOR_SIGN_OUT_PATH;
22767
- }
22768
- function isLocalImageRequest(req) {
22769
- if (req.method !== "GET" && req.method !== "HEAD") return false;
22770
- const { pathname } = new URL(req.url ?? "/", "http://frizz.invalid");
22771
- return LOCAL_IMAGE_PATH.test(pathname);
22772
- }
22773
- var LOCAL_IMAGE_PATH = new RegExp(`^${FRIZZ_ROUTE_PREFIX}(?:/[^/]+)?/local-image$`);
22774
- async function readJsonBody(req, limit = 64 * 1024) {
22775
- const chunks = [];
22776
- let size = 0;
22777
- for await (const chunk of req) {
22778
- size += chunk.byteLength;
22779
- if (size > limit) return null;
22780
- chunks.push(chunk);
22781
- }
22782
- if (chunks.length === 0) return {};
22783
- try {
22784
- const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
22785
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
22786
- } catch {
22787
- return null;
22788
- }
22789
- }
22790
- var RestartSupervisorProxy = class {
22791
- server = null;
22792
- restartInFlight = null;
22793
- /** Which action `restartInFlight` is. An update's prepare phase leaves the child serving; a restart does not. */
22794
- transition = null;
22795
- state = "ready";
22796
- message;
22797
- options;
22798
- host;
22799
- /**
22800
- * The authorities a browser may name for the PUBLIC port. This proxy is the whole boundary: it
22801
- * rewrites Host/Origin to the child's private loopback authority, so the child's own strict gate
22802
- * can never see (or reject) the real browser authority. Everything this class forwards has to be
22803
- * judged here first.
22804
- */
22805
- policy;
22806
- /** The origin a tunnel or proxy serves this board at; undefined while loopback-only. Live: see setPublicOrigin. */
22807
- publicOrigin;
22808
- /**
22809
- * Codes and sessions for the public origin, or null when no public origin is declared (loopback-only
22810
- * boards are never gated, so there is nothing to store).
22811
- */
22812
- access;
22813
- /** Both halves of every live upgraded pair, so close() can actually finish. See close(). */
22814
- upgradedSockets = /* @__PURE__ */ new Set();
22815
- constructor(options2) {
22816
- this.options = options2;
22817
- this.host = options2.host ?? LOOPBACK_BIND_HOST;
22818
- this.publicOrigin = options2.publicOrigin;
22819
- this.access = new AccessStore({
22820
- onConsumed: () => options2.onCodeConsumed?.(),
22821
- ...options2.sessionKey ? { signingKey: options2.sessionKey } : {},
22822
- ...options2.sessionDirectory ? { sessions: options2.sessionDirectory } : {}
22823
- });
22824
- this.policy = this.buildPolicy();
22825
- }
22826
- /**
22827
- * Change the public origin on a running board — what the R pane does when the operator picks or
22828
- * clears a remote setup. Requests already in flight finish under the policy they started with; the
22829
- * next one is judged by the new origin. Sessions survive the change.
22830
- */
22831
- setPublicOrigin(origin) {
22832
- this.publicOrigin = origin;
22833
- this.policy = this.buildPolicy();
22834
- }
22835
- buildPolicy() {
22836
- return {
22837
- exposed: bindHostIsExposed(this.host),
22838
- allowedHosts: this.options.allowedHosts ?? [],
22839
- ...this.publicOrigin ? { publicOrigin: this.publicOrigin } : {}
22840
- };
22841
- }
22842
- /**
22843
- * Mint a single-use code for this board, or null when no public origin is declared.
22844
- *
22845
- * Issuing does NOT invalidate codes already outstanding — pressing the key twice, or two people
22846
- * asking at once, must not silently break the first QR somebody is still walking towards.
22847
- */
22848
- issueAccessCode() {
22849
- return this.publicOrigin ? this.access.issue() : null;
22850
- }
22851
- /** The full URL to show as a link or a QR. */
22852
- accessUrl(code) {
22853
- const origin = this.publicOrigin;
22854
- return origin ? `${origin}/?${ACCESS_CODE_PARAM}=${encodeURIComponent(code)}` : null;
22855
- }
22856
- async listen() {
22857
- if (this.server) return;
22858
- const server = createServer((req, res) => this.handle(req, res));
22859
- server.keepAliveTimeout = 5e3;
22860
- server.headersTimeout = 1e4;
22861
- server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head));
22862
- await new Promise((resolveListen, rejectListen) => {
22863
- server.once("error", rejectListen);
22864
- server.listen(this.options.port, this.host, () => {
22865
- server.off("error", rejectListen);
22866
- resolveListen();
22867
- });
22868
- });
22869
- this.server = server;
22870
- }
22871
- /**
22872
- * Is this request allowed to reach the child at all?
22873
- *
22874
- * Deliberately the WEAKER of the two gates: a missing Origin passes here and the child then applies
22875
- * its own per-route rule for it (only /health, /control/stop and `Sec-Fetch-Site: same-origin` may
22876
- * omit one). What this stops is the thing the child cannot: an Origin naming somebody ELSE being
22877
- * laundered into a valid same-origin one by proxyHeaders below.
22878
- */
22879
- authorityAccepted(req, allowMissingOrigin = true) {
22880
- return isTrustedLocalHttpRequest(req.headers, this.options.port, allowMissingOrigin, this.policy);
22881
- }
22882
- /**
22883
- * Is this an Origin-less request that only LOOKS suspicious because the browser could not send
22884
- * Fetch Metadata to a non-loopback authority? See authoritySendsFetchMetadata.
22885
- *
22886
- * Frizz's missing-Origin rules ask for `Sec-Fetch-Site: same-origin`, which Chrome never sends over
22887
- * plain HTTP to a LAN address — so without this, `--host` serves the shell and then 403s every RPC
22888
- * the app makes. Narrow on purpose: the Host must already be an authority this proxy accepts, there
22889
- * must be no Origin at all (a present one still has to match, and is rejected above if it does not),
22890
- * and loopback traffic is never touched, so the default posture keeps the real Sec-Fetch signal.
22891
- *
22892
- * The condition is `authoritySendsFetchMetadata`, not "is the bind address exposed", because those
22893
- * two came apart with `--public-origin`: a tunnelled board stays on loopback yet is reached at a
22894
- * name. An https proxy origin is potentially trustworthy, so the real signal survives and this
22895
- * never fires for it; a plain-http one is in the same boat as a LAN address and needs the vouch.
22896
- *
22897
- * What this gives up, stated plainly: on an exposed board a cross-site GET carries no Origin and no
22898
- * Fetch Metadata, so it is indistinguishable from the app's own read and is allowed. The response is
22899
- * still opaque to the caller (no CORS), Frizz's GET procedures are reads, and every mutation is a POST
22900
- * — which a browser always stamps with an Origin, and which is therefore still refused.
22901
- */
22902
- vouchesSameOrigin(req) {
22903
- if (req.headers.origin !== void 0 || req.headers["sec-fetch-site"] !== void 0) return false;
22904
- const host = parseLocalHost(req.headers.host, this.options.port, this.policy);
22905
- return !!host && !authoritySendsFetchMetadata(host);
22906
- }
22907
- async close() {
22908
- const server = this.server;
22909
- this.server = null;
22910
- if (!server) return;
22911
- await new Promise((resolveClose, rejectClose) => {
22912
- server.close((error) => error ? rejectClose(error) : resolveClose());
22913
- server.closeAllConnections();
22914
- for (const socket of this.upgradedSockets) socket.destroy();
22915
- this.upgradedSockets.clear();
22916
- });
22917
- }
22918
- status() {
22919
- const delegated = this.options.status?.();
22920
- const updateVersion = this.options.updateVersion?.();
22921
- const version = typeof this.options.version === "function" ? this.options.version() : this.options.version;
22922
- const owner = { state: this.state, ...this.message ? { message: this.message } : {} };
22923
- const reported = this.transition === "update" && delegated ? { ...delegated, ...delegated.state === "ready" ? { preparing: true } : {} } : this.restartInFlight !== null || this.state === "failed" ? owner : delegated ?? owner;
22924
- return {
22925
- ...delegated ?? {},
22926
- ...reported,
22927
- // Never infer this from the generic protocol: legacy/static supervisors can recover a child
22928
- // but cannot build and promote the canonical Frizz artifact.
22929
- updateRestart: typeof this.options.updateRestart === "function",
22930
- // Sent ONLY when the launcher can actually answer it, so an older client — and frizz-dev, which
22931
- // has no notion of "already current" — keeps today's behaviour on its absence.
22932
- ...this.options.updateAvailable ? { updateAvailable: this.options.updateAvailable() === true } : {},
22933
- // The active SERVER version changes on a child-only update. Its absence keeps frizz-dev and
22934
- // old monolithic supervisors' status payload and versionless UI exactly as they were.
22935
- ...version ? { version } : {},
22936
- ...this.options.launcherVersion ? { launcherVersion: this.options.launcherVersion } : {},
22937
- ...updateVersion ? { updateVersion } : {},
22938
- // Sent only when TRUE, so a published Frizz's payload is byte-identical to what it sends today
22939
- // and an older client is unaffected. Absent therefore means "not a development build".
22940
- ...this.options.dev ? { dev: true } : {}
22941
- };
22942
- }
22943
- async runAction(action, transition) {
22944
- if (this.restartInFlight) return this.restartInFlight;
22945
- this.transition = transition;
22946
- this.state = "restarting";
22947
- this.message = void 0;
22948
- const work = (async () => {
22949
- await new Promise((resume) => setImmediate(resume));
22950
- return action();
22951
- })().then(
22952
- (result) => {
22953
- this.state = result.state;
22954
- this.message = result.message;
22955
- return result;
22956
- },
22957
- (error) => {
22958
- const result = { state: "failed", message: error instanceof Error ? error.message : String(error) };
22959
- this.state = result.state;
22960
- this.message = result.message;
22961
- return result;
22962
- }
22963
- ).finally(() => {
22964
- this.restartInFlight = null;
22965
- this.transition = null;
22966
- });
22967
- this.restartInFlight = work;
22968
- return work;
22969
- }
22970
- async handleControl(req, res) {
22971
- const pathname = new URL(req.url ?? "/", "http://frizz.invalid").pathname;
22972
- if (pathname !== SUPERVISOR_SIGN_OUT_PATH && this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
22973
- res.writeHead(401, { "cache-control": "no-store" });
22974
- res.end();
22975
- return;
22976
- }
22977
- const sameOrigin = req.headers["sec-fetch-site"] === "same-origin" || this.vouchesSameOrigin(req);
22978
- const allowMissingOrigin = pathname === SUPERVISOR_STATUS_PATH && req.method === "GET" && sameOrigin;
22979
- if (!this.authorityAccepted(req, allowMissingOrigin)) {
22980
- res.writeHead(403);
22981
- res.end("Forbidden");
22982
- return;
22983
- }
22984
- if (pathname === SUPERVISOR_ACCESS_CODE_PATH) {
22985
- if (this.arrivedPublicly(req)) {
22986
- res.writeHead(403);
22987
- res.end("Forbidden");
22988
- return;
22989
- }
22990
- if (req.method !== "POST") {
22991
- res.writeHead(405, { allow: "POST" });
22992
- res.end();
22993
- return;
22994
- }
22995
- const link = this.issueAccessCode();
22996
- const url = link && this.accessUrl(link.code);
22997
- if (!link || !url) {
22998
- responseJson(res, 409, { error: "this board has no public origin, so there is nothing to link to" });
22999
- return;
23000
- }
23001
- responseJson(res, 200, { url, expiresAt: link.expiresAt });
23002
- return;
23003
- }
23004
- if (pathname === SUPERVISOR_SESSIONS_PATH) {
23005
- if (this.arrivedPublicly(req)) {
23006
- res.writeHead(403);
23007
- res.end("Forbidden");
23008
- return;
23009
- }
23010
- if (!this.publicOrigin) {
23011
- responseJson(res, 409, { error: "this board has no public origin, so nothing is signed in" });
23012
- return;
23013
- }
23014
- if (req.method === "GET") {
23015
- responseJson(res, 200, { sessions: this.access.sessions.list() });
23016
- return;
23017
- }
23018
- if (req.method !== "POST") {
23019
- res.writeHead(405, { allow: "GET, POST" });
23020
- res.end();
23021
- return;
23022
- }
23023
- const body = await readJsonBody(req);
23024
- const id = typeof body?.id === "string" ? body.id : void 0;
23025
- if (body?.all === true) {
23026
- responseJson(res, 200, { signedOut: this.access.sessions.revokeAll() });
23027
- return;
23028
- }
23029
- if (!id) {
23030
- responseJson(res, 400, { error: "name a device id, or pass all: true" });
23031
- return;
23032
- }
23033
- if (!this.access.sessions.revoke(id)) {
23034
- responseJson(res, 404, { error: `no signed-in device has the id ${id}` });
23035
- return;
23036
- }
23037
- responseJson(res, 200, { signedOut: 1 });
23038
- return;
23039
- }
23040
- if (pathname === SUPERVISOR_SIGN_OUT_PATH) {
23041
- if (req.method !== "POST") {
23042
- res.writeHead(405, { allow: "POST" });
23043
- res.end();
23044
- return;
23045
- }
23046
- this.signOutThisDevice(req, res);
23047
- return;
23048
- }
23049
- if (pathname === SUPERVISOR_STATUS_PATH && req.method === "GET") {
23050
- responseJson(res, 200, {
23051
- protocol: SUPERVISOR_CONTROL_PROTOCOL,
23052
- ...this.status(),
23053
- // Sent ONLY when true, so a loopback tab's payload is byte-identical to what it was. The web
23054
- // app cannot tell on its own: the session cookie is HttpOnly, and the page is the same bundle
23055
- // whichever way it was reached. It is a fact about THIS request, never about the board.
23056
- ...this.carriesRemoteSession(req) ? { remoteSession: true } : {}
23057
- });
23058
- return;
23059
- }
23060
- if (pathname !== SUPERVISOR_RESTART_PATH && pathname !== SUPERVISOR_UPDATE_RESTART_PATH || req.method !== "POST") {
23061
- res.writeHead(405, { allow: pathname === SUPERVISOR_STATUS_PATH ? "GET" : "POST" });
23062
- res.end();
23063
- return;
23064
- }
23065
- if (pathname === SUPERVISOR_UPDATE_RESTART_PATH && !this.options.updateRestart) {
23066
- responseJson(res, 409, { protocol: SUPERVISOR_CONTROL_PROTOCOL, state: "failed", message: "Update & Restart is available only for a stable immutable Frizz artifact" });
23067
- return;
23068
- }
23069
- if (pathname === SUPERVISOR_UPDATE_RESTART_PATH) {
23070
- void this.runAction(this.options.updateRestart, "update");
23071
- responseJson(res, 202, { protocol: SUPERVISOR_CONTROL_PROTOCOL, state: "restarting" });
23072
- return;
23073
- }
23074
- const result = await this.runAction(this.options.restart, "restart");
23075
- responseJson(res, result.state === "ready" ? 202 : 503, { protocol: SUPERVISOR_CONTROL_PROTOCOL, ...result });
23076
- }
23077
- handleLocalImage(req, res) {
23078
- const allowMissingOrigin = req.headers.origin === void 0 && (req.headers["sec-fetch-site"] === "same-origin" || this.vouchesSameOrigin(req));
23079
- if (!this.authorityAccepted(req, allowMissingOrigin)) {
23080
- res.writeHead(403, { "content-type": "text/plain; charset=UTF-8" });
23081
- res.end("Forbidden");
23082
- return;
23317
+ if (table.size() < 4095) {
23318
+ if (table.size() == 1 << bitLength) {
23319
+ bitLength += 1;
23320
+ }
23321
+ table.add(s + c);
23322
+ }
23323
+ s = c;
23324
+ }
23083
23325
  }
23084
- const url = new URL(req.url ?? "/", "http://frizz.invalid");
23085
- const result = resolveLocalImage(url.searchParams.get("path") ?? void 0);
23086
- const origin = typeof req.headers.origin === "string" ? allowedLocalCorsOrigin(req.headers.origin, this.options.port, this.policy) : void 0;
23087
- const sharedHeaders = {
23088
- ...origin ? { "access-control-allow-origin": origin } : {},
23089
- "access-control-expose-headers": "x-frizz-boot",
23090
- vary: "Origin"
23326
+ bitOut.write(table.indexOf(s), bitLength);
23327
+ bitOut.write(endCode, bitLength);
23328
+ bitOut.flush();
23329
+ return byteOut.toByteArray();
23330
+ };
23331
+ const lzwTable = function() {
23332
+ const _map = {};
23333
+ let _size = 0;
23334
+ const _this2 = {};
23335
+ _this2.add = function(key) {
23336
+ if (_this2.contains(key)) {
23337
+ throw "dup key:" + key;
23338
+ }
23339
+ _map[key] = _size;
23340
+ _size += 1;
23091
23341
  };
23092
- if (result.status !== 200) {
23093
- res.writeHead(result.status, { ...sharedHeaders, "content-type": "text/plain; charset=UTF-8" });
23094
- res.end(String(result.status));
23095
- return;
23096
- }
23097
- res.writeHead(200, {
23098
- ...sharedHeaders,
23099
- "content-type": result.contentType,
23100
- "content-length": result.body.length,
23101
- "cache-control": "private, max-age=60"
23102
- });
23103
- res.end(req.method === "HEAD" ? void 0 : result.body);
23104
- }
23105
- /**
23106
- * Is this request arriving as the declared public origin rather than over loopback?
23107
- *
23108
- * The token gate keys on this and not on "is the port exposed", because the two are independent:
23109
- * a tunnelled board stays bound to 127.0.0.1 while being reachable from the internet by name.
23110
- */
23111
- arrivedPublicly(req) {
23112
- const declared = this.publicOrigin;
23113
- if (!declared) return false;
23114
- const host = parseLocalHost(req.headers.host, this.options.port, this.policy);
23115
- return !!host && !LOOPBACK_HOSTNAMES.has(host.hostname);
23116
- }
23117
- /**
23118
- * Bearer check for the public origin. Two ways in: the cookie the exchange below sets, or the
23119
- * one-time `?frizz_token=` in the URL the operator was handed at launch.
23120
- *
23121
- * Compared with `timingSafeEqual` on equal-length buffers — a plain `===` on a secret leaks its
23122
- * prefix to a patient attacker, and this secret is the only thing between the internet and a shell.
23123
- */
23124
- /**
23125
- * Is this request carrying a session this process minted?
23126
- *
23127
- * Sessions only. A raw `?frizz_code=` is NOT accepted here — it is redeemed once by exchangeCode()
23128
- * below and traded for a session, so a code that has already been spent cannot keep working just by
23129
- * staying in somebody's URL bar.
23130
- */
23131
- sessionAccepted(req) {
23132
- if (!this.publicOrigin) return true;
23133
- return this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
23134
- }
23135
- /** Did this request arrive through the public origin carrying a live session? Loopback never does. */
23136
- carriesRemoteSession(req) {
23137
- return this.arrivedPublicly(req) && this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
23138
- }
23139
- /**
23140
- * End the session this request carries, and nothing else — see SUPERVISOR_SIGN_OUT_PATH in
23141
- * handleControl for why that makes it safe to reach from the tunnel.
23142
- *
23143
- * A loopback request is a no-op even if it somehow carries a cookie: the operator's own tab has no
23144
- * remote session to end, and loopback is the place `--sign-out` already covers.
23145
- */
23146
- signOutThisDevice(req, res) {
23147
- const reply = (status, body, clearCookie) => {
23148
- res.writeHead(status, {
23149
- "content-type": "application/json; charset=utf-8",
23150
- "cache-control": "no-store",
23151
- // Same attributes the exchange set, so the browser matches and drops that exact cookie.
23152
- ...clearCookie ? { "set-cookie": `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` } : {}
23153
- });
23154
- res.end(JSON.stringify(body));
23342
+ _this2.size = function() {
23343
+ return _size;
23155
23344
  };
23156
- if (!this.arrivedPublicly(req)) {
23157
- reply(200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "no-remote-session" }, false);
23158
- return;
23159
- }
23160
- const outcome = this.access.signOutSession(readCookie(req.headers.cookie, SESSION_COOKIE));
23161
- if (outcome.result === "revoked") {
23162
- reply(200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "signed-out", id: outcome.id }, true);
23163
- return;
23164
- }
23165
- if (outcome.result === "legacy") {
23166
- reply(200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "cookie-cleared" }, true);
23167
- return;
23345
+ _this2.indexOf = function(key) {
23346
+ return _map[key];
23347
+ };
23348
+ _this2.contains = function(key) {
23349
+ return typeof _map[key] != "undefined";
23350
+ };
23351
+ return _this2;
23352
+ };
23353
+ return _this;
23354
+ };
23355
+ var createDataURL = function(width, height, getPixel) {
23356
+ const gif = gifImage(width, height);
23357
+ for (let y = 0; y < height; y += 1) {
23358
+ for (let x = 0; x < width; x += 1) {
23359
+ gif.setPixel(x, y, getPixel(x, y));
23168
23360
  }
23169
- reply(401, { protocol: SUPERVISOR_CONTROL_PROTOCOL, result: "no-remote-session" }, true);
23170
23361
  }
23171
- /**
23172
- * Trade `?frizz_code=…` for a cookie, then bounce to the same URL
23173
- * without the secret, so it never lands in history, a Referer header, or a screenshot.
23174
- */
23175
- exchangeCode(req, res) {
23176
- const url = new URL(req.url ?? "/", "http://frizz.invalid");
23177
- const code = url.searchParams.get(ACCESS_CODE_PARAM);
23178
- if (code === null) return false;
23179
- const redeemed = this.access.redeem(code, describeDevice(req.headers["user-agent"]));
23180
- if (!redeemed?.ok) {
23181
- res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
23182
- res.end(unauthorizedPage(redeemed?.reason));
23183
- return true;
23184
- }
23185
- const cookie = `${SESSION_COOKIE}=${redeemed.session}`;
23186
- url.searchParams.delete(ACCESS_CODE_PARAM);
23187
- const target2 = `${url.pathname}${url.search}${url.hash}` || "/";
23188
- res.writeHead(302, {
23189
- location: target2,
23190
- "set-cookie": `${cookie}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000`,
23191
- "cache-control": "no-store"
23192
- });
23193
- res.end();
23194
- return true;
23362
+ const b = byteArrayOutputStream();
23363
+ gif.write(b);
23364
+ const base64 = base64EncodeOutputStream();
23365
+ const bytes = b.toByteArray();
23366
+ for (let i = 0; i < bytes.length; i += 1) {
23367
+ base64.writeByte(bytes[i]);
23195
23368
  }
23196
- handle(req, res) {
23197
- if (isControlRequest(req)) {
23198
- void this.handleControl(req, res);
23199
- return;
23200
- }
23201
- if (this.arrivedPublicly(req) && this.exchangeCode(req, res)) return;
23202
- if (this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
23203
- res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
23204
- res.end(unauthorizedPage());
23205
- return;
23369
+ base64.flush();
23370
+ return "data:image/gif;base64," + base64;
23371
+ };
23372
+ var qrcode_default = qrcode;
23373
+ var stringToBytes = qrcode.stringToBytes;
23374
+
23375
+ // packages/server/src/qr.ts
23376
+ var DARK = 232;
23377
+ var LIGHT = 231;
23378
+ var fg = (dark) => `\x1B[38;5;${dark ? DARK : LIGHT}m`;
23379
+ var bg = (dark) => `\x1B[48;5;${dark ? DARK : LIGHT}m`;
23380
+ var RESET = "\x1B[0m";
23381
+ var UPPER_HALF = "\u2580";
23382
+ var LOWER_HALF = "\u2584";
23383
+ var QUIET_ZONE = 4;
23384
+ var BLOCK_DRAWING_PROGRAMS = /* @__PURE__ */ new Set(["iTerm.app", "WezTerm", "ghostty", "WarpTerminal", "vscode"]);
23385
+ var BLOCK_DRAWING_TERMS = /* @__PURE__ */ new Set(["xterm-kitty", "xterm-ghostty", "alacritty", "wezterm"]);
23386
+ function drawsBlockElements(env = process.env) {
23387
+ const term = env.TERM ?? "";
23388
+ if (env.TMUX || env.STY || env.TERM_PROGRAM === "tmux" || term.startsWith("screen") || term.startsWith("tmux")) return false;
23389
+ if (BLOCK_DRAWING_PROGRAMS.has(env.TERM_PROGRAM ?? "")) return true;
23390
+ if (BLOCK_DRAWING_TERMS.has(term)) return true;
23391
+ if (env.LC_TERMINAL === "iTerm2") return true;
23392
+ return Boolean(
23393
+ env.KITTY_WINDOW_ID || env.ALACRITTY_WINDOW_ID || env.WEZTERM_PANE || env.GHOSTTY_RESOURCES_DIR || env.WT_SESSION || env.VTE_VERSION
23394
+ );
23395
+ }
23396
+ function encode(value, errorCorrection) {
23397
+ const code = qrcode_default(0, errorCorrection);
23398
+ code.addData(value);
23399
+ code.make();
23400
+ const modules = code.getModuleCount();
23401
+ const size = modules + QUIET_ZONE * 2;
23402
+ return {
23403
+ size,
23404
+ at: (x, y) => {
23405
+ const mx = x - QUIET_ZONE;
23406
+ const my = y - QUIET_ZONE;
23407
+ if (mx < 0 || my < 0 || mx >= modules || my >= modules) return false;
23408
+ return code.isDark(my, mx);
23206
23409
  }
23207
- if (!this.authorityAccepted(req)) {
23208
- const name = unlistedHostName(req.headers.host, this.options.port, this.policy);
23209
- if (name) {
23210
- res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
23211
- res.end(req.method === "HEAD" ? void 0 : unlistedHostPage(name));
23212
- return;
23410
+ };
23411
+ }
23412
+ function sizeOf(size, style) {
23413
+ return style === "paint" ? { columns: size * 2, rows: size } : { columns: size, rows: Math.ceil(size / 2) };
23414
+ }
23415
+ function qrSize(value, style = "half", errorCorrection = "M") {
23416
+ return sizeOf(encode(value, errorCorrection).size, style);
23417
+ }
23418
+ function qrStyleFor(value, options2 = {}) {
23419
+ const { area } = options2;
23420
+ if (!area || drawsBlockElements(options2.env)) return "half";
23421
+ const paint = qrSize(value, "paint", options2.errorCorrection);
23422
+ return paint.columns <= area.columns && paint.rows <= area.rows ? "paint" : "half";
23423
+ }
23424
+ function qrAreaOf(stream, frame) {
23425
+ if (!stream.columns || !stream.rows) return void 0;
23426
+ return { columns: stream.columns - frame.indent, rows: stream.rows - frame.rows };
23427
+ }
23428
+ function halfLines(size, at, plain) {
23429
+ const lines = [];
23430
+ for (let y = 0; y < size; y += 2) {
23431
+ let line = "";
23432
+ for (let x = 0; x < size; x++) {
23433
+ const top = at(x, y);
23434
+ const bottom = y + 1 < size ? at(x, y + 1) : false;
23435
+ if (plain) {
23436
+ line += top && bottom ? "#" : top ? "^" : bottom ? "v" : " ";
23437
+ continue;
23213
23438
  }
23214
- res.writeHead(403, { "content-type": "text/plain; charset=UTF-8" });
23215
- res.end("Forbidden");
23216
- return;
23217
- }
23218
- if (isLocalImageRequest(req)) {
23219
- this.handleLocalImage(req, res);
23220
- return;
23221
- }
23222
- const childPort = this.options.childPort();
23223
- if (!childPort) {
23224
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "retry-after": "2" });
23225
- res.end(recoveryPage(req.url ?? "/"));
23226
- return;
23439
+ if (top === bottom) {
23440
+ line += `${bg(top)} `;
23441
+ continue;
23442
+ }
23443
+ const above = y > 0 ? at(x, y - 1) : false;
23444
+ const below = y + 2 < size ? at(x, y + 2) : false;
23445
+ line += below === bottom && above !== top ? `${fg(top)}${bg(bottom)}${UPPER_HALF}` : `${fg(bottom)}${bg(top)}${LOWER_HALF}`;
23227
23446
  }
23228
- const upstream = requestHttp({
23229
- host: "127.0.0.1",
23230
- port: childPort,
23231
- method: req.method,
23232
- path: req.url,
23233
- headers: proxyHeaders(req, childPort, this.vouchesSameOrigin(req))
23234
- }, (upstreamResponse) => {
23235
- res.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
23236
- upstreamResponse.pipe(res);
23237
- });
23238
- upstream.once("error", () => {
23239
- if (res.headersSent) return res.destroy();
23240
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
23241
- res.end(recoveryPage(req.url ?? "/", "unreachable"));
23242
- });
23243
- req.pipe(upstream);
23447
+ lines.push(plain ? line : `${line}${RESET}`);
23244
23448
  }
23245
- handleUpgrade(req, socket, head) {
23246
- if (this.arrivedPublicly(req) && !this.sessionAccepted(req)) {
23247
- rejectWebSocketUpgrade(socket, 401, "Unauthorized");
23248
- return;
23249
- }
23250
- if (!this.authorityAccepted(req, false)) {
23251
- socket.destroy();
23252
- return;
23253
- }
23254
- const childPort = this.options.childPort();
23255
- if (!childPort) {
23256
- socket.destroy();
23257
- return;
23258
- }
23259
- const upstream = connect(childPort, "127.0.0.1");
23260
- for (const half of [socket, upstream]) {
23261
- this.upgradedSockets.add(half);
23262
- half.once("close", () => this.upgradedSockets.delete(half));
23263
- }
23264
- upstream.once("connect", () => {
23265
- const headers = proxyHeaders(req, childPort);
23266
- headers.connection = req.headers.connection ?? "Upgrade";
23267
- const lines = [`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`];
23268
- for (const [name, value] of Object.entries(headers)) {
23269
- if (value === void 0) continue;
23270
- for (const entry of Array.isArray(value) ? value : [value]) lines.push(`${name}: ${entry}`);
23449
+ return lines;
23450
+ }
23451
+ function paintLines(size, at, plain) {
23452
+ const lines = [];
23453
+ for (let y = 0; y < size; y++) {
23454
+ let line = "";
23455
+ let last;
23456
+ for (let x = 0; x < size; x++) {
23457
+ const dark = at(x, y);
23458
+ if (plain) line += dark ? "##" : " ";
23459
+ else {
23460
+ if (dark !== last) line += bg(dark);
23461
+ line += " ";
23271
23462
  }
23272
- upstream.write(`${lines.join("\r\n")}\r
23273
- \r
23274
- `);
23275
- if (head.length > 0) upstream.write(head);
23276
- socket.pipe(upstream).pipe(socket);
23277
- });
23278
- upstream.once("error", () => socket.destroy());
23279
- socket.once("error", () => upstream.destroy());
23463
+ last = dark;
23464
+ }
23465
+ lines.push(plain ? line : `${line}${RESET}`);
23280
23466
  }
23281
- };
23467
+ return lines;
23468
+ }
23469
+ function renderQrLines(value, options2 = {}) {
23470
+ if (!value) throw new Error("renderQr requires a value");
23471
+ const errorCorrection = options2.errorCorrection ?? "M";
23472
+ const { size, at } = encode(value, errorCorrection);
23473
+ const style = options2.style ?? qrStyleFor(value, { area: options2.area, env: options2.env, errorCorrection });
23474
+ return style === "paint" ? paintLines(size, at, options2.plain ?? false) : halfLines(size, at, options2.plain ?? false);
23475
+ }
23282
23476
 
23283
23477
  // src/sessions-cli.ts
23478
+ init_restart_supervisor();
23284
23479
  var ago = (at, now = Date.now()) => {
23285
23480
  const seconds = Math.max(0, Math.round((now - at) / 1e3));
23286
23481
  if (seconds < 90) return "just now";
@@ -23309,9 +23504,10 @@ async function call(port, init) {
23309
23504
  async function listSessions(port) {
23310
23505
  const body = await call(port);
23311
23506
  const sessions = body?.sessions ?? [];
23312
- const live = sessions.filter((s) => s.revokedAt === void 0);
23507
+ const now = Date.now();
23508
+ const live = sessions.filter((s) => s.revokedAt === void 0 && (s.expiresAt === void 0 || s.expiresAt > now));
23313
23509
  if (live.length === 0) {
23314
- console.log("No device has redeemed an access link.");
23510
+ console.log("No device is signed in.");
23315
23511
  process.exit(0);
23316
23512
  }
23317
23513
  const width = Math.max(...live.map((s) => s.id.length));
@@ -23338,6 +23534,9 @@ async function signOutSession(port, target2) {
23338
23534
  process.exit(0);
23339
23535
  }
23340
23536
 
23537
+ // src/production.ts
23538
+ init_restart_supervisor();
23539
+
23341
23540
  // src/pane-host.ts
23342
23541
  var CTRL_C = "";
23343
23542
  function raiseInterrupt() {
@@ -23506,7 +23705,7 @@ import { homedir as homedir5 } from "node:os";
23506
23705
  init_frizz_paths();
23507
23706
  init_src();
23508
23707
  import { spawn as spawn2 } from "node:child_process";
23509
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3, rmSync } from "node:fs";
23708
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, rmSync } from "node:fs";
23510
23709
  import { createInterface } from "node:readline/promises";
23511
23710
  import { homedir as homedir4 } from "node:os";
23512
23711
  import { dirname as dirname4, join as join5 } from "node:path";
@@ -23514,7 +23713,7 @@ import { dirname as dirname4, join as join5 } from "node:path";
23514
23713
  // src/identity.ts
23515
23714
  init_frizz_paths();
23516
23715
  init_src();
23517
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
23716
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
23518
23717
  import { homedir as homedir3 } from "node:os";
23519
23718
  import { dirname as dirname3, join as join4 } from "node:path";
23520
23719
  function claimIdentityPath(home = homedir3(), env) {
@@ -23524,7 +23723,7 @@ async function loadOrCreateClaimIdentity(home = homedir3()) {
23524
23723
  const path = claimIdentityPath(home);
23525
23724
  let stored;
23526
23725
  try {
23527
- stored = readFileSync3(path);
23726
+ stored = readFileSync2(path);
23528
23727
  } catch {
23529
23728
  stored = void 0;
23530
23729
  }
@@ -23541,52 +23740,144 @@ async function loadOrCreateClaimIdentity(home = homedir3()) {
23541
23740
  return identity;
23542
23741
  }
23543
23742
 
23544
- // src/github-identity.ts
23545
- import { execFile as execFile2 } from "node:child_process";
23546
- import { promisify } from "node:util";
23547
- var run = promisify(execFile2);
23548
- var GithubIdentityError = class extends Error {
23743
+ // src/github-device-flow.ts
23744
+ var GITHUB_CLIENT_ID_PLACEHOLDER = "frizz-oauth-app-client-id-not-set";
23745
+ var FRIZZ_GITHUB_CLIENT_ID = GITHUB_CLIENT_ID_PLACEHOLDER;
23746
+ function githubClientId(env = process.env) {
23747
+ const id = env.FRIZZ_GITHUB_CLIENT_ID?.trim() || FRIZZ_GITHUB_CLIENT_ID;
23748
+ return id === GITHUB_CLIENT_ID_PLACEHOLDER ? null : id;
23749
+ }
23750
+ var GithubSignInError = class extends Error {
23549
23751
  constructor(message, code) {
23550
23752
  super(message);
23551
23753
  this.code = code;
23552
- this.name = "GithubIdentityError";
23754
+ this.name = "GithubSignInError";
23553
23755
  }
23554
23756
  };
23555
- async function githubAccessToken() {
23556
- let stdout;
23757
+ var GITHUB_DEVICE_URL = "https://github.com/login/device";
23758
+ function abortableSleep(ms, signal) {
23759
+ return new Promise((resolve7, reject) => {
23760
+ if (signal?.aborted) return reject(cancelled());
23761
+ const timer = setTimeout(() => {
23762
+ signal?.removeEventListener("abort", onAbort);
23763
+ resolve7();
23764
+ }, ms);
23765
+ const onAbort = () => {
23766
+ clearTimeout(timer);
23767
+ reject(cancelled());
23768
+ };
23769
+ signal?.addEventListener("abort", onAbort, { once: true });
23770
+ });
23771
+ }
23772
+ var cancelled = () => new GithubSignInError("the GitHub sign-in was cancelled", "cancelled");
23773
+ async function postForm(fetchImpl, url, fields, signal) {
23774
+ let response;
23557
23775
  try {
23558
- ({ stdout } = await run("gh", ["auth", "token"], { encoding: "utf8" }));
23776
+ response = await fetchImpl(url, {
23777
+ method: "POST",
23778
+ headers: {
23779
+ accept: "application/json",
23780
+ "content-type": "application/x-www-form-urlencoded",
23781
+ "user-agent": "frizz"
23782
+ },
23783
+ body: new URLSearchParams(fields).toString(),
23784
+ ...signal ? { signal } : {}
23785
+ });
23559
23786
  } catch (error) {
23560
- const code = error.code;
23561
- if (code === "ENOENT") {
23562
- throw new GithubIdentityError(
23563
- "claiming a name needs the GitHub CLI, which is not installed \u2014 see https://cli.github.com, then run `gh auth login`",
23564
- "missing-gh"
23565
- );
23566
- }
23567
- throw new GithubIdentityError(
23568
- "claiming a name needs a signed-in GitHub CLI \u2014 run `gh auth login` and try again",
23569
- "not-signed-in"
23787
+ if (signal?.aborted) throw cancelled();
23788
+ throw new GithubSignInError(
23789
+ `could not reach GitHub to sign in: ${error instanceof Error ? error.message : error}`,
23790
+ "unreachable"
23791
+ );
23792
+ }
23793
+ const body = await response.json().catch(() => null);
23794
+ return { status: response.status, body: body ?? {} };
23795
+ }
23796
+ var oauthErrorText = (body, fallback) => typeof body.error_description === "string" && body.error_description ? body.error_description : typeof body.error === "string" && body.error ? body.error : fallback;
23797
+ async function runDeviceFlow(options2) {
23798
+ const clientId = options2.clientId === void 0 ? githubClientId() : options2.clientId;
23799
+ if (!clientId) {
23800
+ throw new GithubSignInError(
23801
+ "custom frizz.sh names are not available in this version of Frizz yet: its GitHub sign-in is not set up. A private frizz.sh name needs no account and works today.",
23802
+ "not-configured"
23570
23803
  );
23571
23804
  }
23572
- const token = stdout.trim();
23573
- if (!token) {
23574
- throw new GithubIdentityError(
23575
- "the GitHub CLI returned no token \u2014 run `gh auth login` and try again",
23576
- "not-signed-in"
23805
+ const fetchImpl = options2.fetchImpl ?? fetch;
23806
+ const now = options2.now ?? Date.now;
23807
+ const sleep = options2.sleep ?? abortableSleep;
23808
+ const githubOrigin = (options2.githubOrigin ?? "https://github.com").replace(/\/$/, "");
23809
+ const apiOrigin = (options2.apiOrigin ?? "https://api.github.com").replace(/\/$/, "");
23810
+ const { signal } = options2;
23811
+ const start = await postForm(fetchImpl, `${githubOrigin}/login/device/code`, { client_id: clientId }, signal);
23812
+ const deviceCode = start.body.device_code;
23813
+ const userCode = start.body.user_code;
23814
+ if (start.status !== 200 || typeof deviceCode !== "string" || typeof userCode !== "string") {
23815
+ throw new GithubSignInError(
23816
+ `GitHub would not start a sign-in: ${oauthErrorText(start.body, `HTTP ${start.status}`)}`,
23817
+ "failed"
23818
+ );
23819
+ }
23820
+ const verificationUri = typeof start.body.verification_uri === "string" ? start.body.verification_uri : GITHUB_DEVICE_URL;
23821
+ const expiresIn = typeof start.body.expires_in === "number" ? start.body.expires_in : 900;
23822
+ let interval = typeof start.body.interval === "number" ? start.body.interval : 5;
23823
+ const expiresAt = now() + expiresIn * 1e3;
23824
+ options2.onPrompt({ userCode, verificationUri, expiresAt });
23825
+ for (; ; ) {
23826
+ await sleep(interval * 1e3, signal);
23827
+ if (now() >= expiresAt) {
23828
+ throw new GithubSignInError("the GitHub code expired before it was entered \u2014 try again", "expired");
23829
+ }
23830
+ const poll = await postForm(
23831
+ fetchImpl,
23832
+ `${githubOrigin}/login/oauth/access_token`,
23833
+ { client_id: clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
23834
+ signal
23577
23835
  );
23836
+ const error = poll.body.error;
23837
+ if (error === "authorization_pending") continue;
23838
+ if (error === "slow_down") {
23839
+ interval = typeof poll.body.interval === "number" ? poll.body.interval : interval + 5;
23840
+ continue;
23841
+ }
23842
+ if (error === "expired_token") {
23843
+ throw new GithubSignInError("the GitHub code expired before it was entered \u2014 try again", "expired");
23844
+ }
23845
+ if (error === "access_denied") {
23846
+ throw new GithubSignInError("the GitHub sign-in was declined, so no name was claimed", "denied");
23847
+ }
23848
+ const token = poll.body.access_token;
23849
+ if (typeof error === "string" || typeof token !== "string" || !token) {
23850
+ throw new GithubSignInError(`GitHub sign-in failed: ${oauthErrorText(poll.body, `HTTP ${poll.status}`)}`, "failed");
23851
+ }
23852
+ const scope = typeof poll.body.scope === "string" ? poll.body.scope.trim() : "";
23853
+ if (scope) {
23854
+ throw new GithubSignInError(
23855
+ `GitHub granted this sign-in more access than a name needs (${scope}), so Frizz did not use it`,
23856
+ "scoped"
23857
+ );
23858
+ }
23859
+ return { token, login: await readLogin(fetchImpl, apiOrigin, token) };
23578
23860
  }
23579
- return token;
23580
23861
  }
23581
- async function githubLogin() {
23862
+ async function readLogin(fetchImpl, apiOrigin, token) {
23582
23863
  try {
23583
- const { stdout } = await run("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8" });
23584
- return stdout.trim() || null;
23864
+ const response = await fetchImpl(`${apiOrigin}/user`, {
23865
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "frizz" }
23866
+ });
23867
+ if (!response.ok) return null;
23868
+ const user = await response.json();
23869
+ return typeof user.login === "string" ? user.login : null;
23585
23870
  } catch {
23586
23871
  return null;
23587
23872
  }
23588
23873
  }
23589
- var githubCli = { accessToken: githubAccessToken, login: githubLogin };
23874
+ function deviceCodeMessage(prompt) {
23875
+ return `to confirm your GitHub account, open ${prompt.verificationUri} and enter ${prompt.userCode} (Frizz asks GitHub for no permissions)`;
23876
+ }
23877
+ function githubDeviceFlow(options2 = {}) {
23878
+ const onPrompt = options2.onPrompt ?? ((prompt) => console.log(` ${deviceCodeMessage(prompt)}`));
23879
+ return { authorize: () => runDeviceFlow({ onPrompt, ...options2.signal ? { signal: options2.signal } : {} }) };
23880
+ }
23590
23881
 
23591
23882
  // src/registrar-client.ts
23592
23883
  init_src();
@@ -23596,8 +23887,9 @@ function registrarOrigin(env = process.env) {
23596
23887
  }
23597
23888
  var ADVICE = {
23598
23889
  "name-taken": "that name is already taken \u2014 try another",
23599
- "github-required": "claiming a name needs a signed-in GitHub CLI \u2014 run `gh auth login` and try again",
23600
- "github-rejected": "GitHub did not recognise that login \u2014 run `gh auth login` and try again",
23890
+ "github-required": "claiming a custom name needs a GitHub sign-in \u2014 try again and enter the code GitHub shows",
23891
+ "github-rejected": "GitHub did not recognise that sign-in \u2014 try again",
23892
+ "github-token-scoped": "the registrar accepts only a GitHub sign-in that grants no permissions \u2014 update Frizz and try again",
23601
23893
  "github-too-new": "that GitHub account is too new to claim a name",
23602
23894
  "one-name-per-account": "that GitHub account already holds a name \u2014 release it first",
23603
23895
  "one-name-per-key": "this machine already holds a name \u2014 release it first",
@@ -23674,11 +23966,11 @@ function serveRelayRequest(frame, options2) {
23674
23966
  const target2 = new URL(frame.url);
23675
23967
  const local = new URL(options2.origin);
23676
23968
  const publicUrl = new URL(options2.publicOrigin);
23677
- let cancelled = false;
23969
+ let cancelled2 = false;
23678
23970
  let abort;
23679
23971
  const done = new Promise((resolve7) => {
23680
23972
  const send = (upFrame) => {
23681
- if (!cancelled) options2.send(upFrame);
23973
+ if (!cancelled2) options2.send(upFrame);
23682
23974
  };
23683
23975
  const headers = {};
23684
23976
  for (const [name, value] of stripHopByHop(frame.headers)) headers[name] = value;
@@ -23772,8 +24064,8 @@ function serveRelayRequest(frame, options2) {
23772
24064
  return {
23773
24065
  done,
23774
24066
  cancel: () => {
23775
- if (cancelled) return;
23776
- cancelled = true;
24067
+ if (cancelled2) return;
24068
+ cancelled2 = true;
23777
24069
  abort?.();
23778
24070
  }
23779
24071
  };
@@ -24053,7 +24345,7 @@ function zoneClaimLabel(hostname) {
24053
24345
  const label = hostname.slice(0, -suffix.length);
24054
24346
  return label.length > 0 && !label.includes(".") ? label : null;
24055
24347
  }
24056
- async function reconcileCloudConfig(config, port, home = homedir4(), onNotice, origin, github = githubCli) {
24348
+ async function reconcileCloudConfig(config, port, home = homedir4(), onNotice, origin, github = githubDeviceFlow(onNotice ? { onPrompt: (prompt) => onNotice(deviceCodeMessage(prompt)) } : {})) {
24057
24349
  if (isRelayConfig(config)) return config;
24058
24350
  const label = config.claim ?? zoneClaimLabel(config.hostname);
24059
24351
  if (!label) return config;
@@ -24067,7 +24359,7 @@ function cloudConfigPath(home = homedir4()) {
24067
24359
  }
24068
24360
  function readCloudConfig(home = homedir4()) {
24069
24361
  try {
24070
- const parsed = JSON.parse(readFileSync4(cloudConfigPath(home), "utf8"));
24362
+ const parsed = JSON.parse(readFileSync3(cloudConfigPath(home), "utf8"));
24071
24363
  if (typeof parsed.hostname !== "string" || !parsed.hostname) return null;
24072
24364
  const claimed = typeof parsed.claim === "string" && parsed.claim.length > 0;
24073
24365
  const named = typeof parsed.tunnel === "string" && parsed.tunnel.length > 0;
@@ -24090,7 +24382,7 @@ function tunnelTokenPath(home = homedir4()) {
24090
24382
  }
24091
24383
  function readTunnelToken(home = homedir4()) {
24092
24384
  try {
24093
- const token = readFileSync4(tunnelTokenPath(home), "utf8").trim();
24385
+ const token = readFileSync3(tunnelTokenPath(home), "utf8").trim();
24094
24386
  return token.length > 0 ? token : null;
24095
24387
  } catch {
24096
24388
  return null;
@@ -24128,7 +24420,7 @@ async function promptForTunnelName(hostname, fallback) {
24128
24420
  rl.close();
24129
24421
  }
24130
24422
  }
24131
- async function establishCloudConfig(answer, port, home = homedir4(), origin, github = githubCli) {
24423
+ async function establishCloudConfig(answer, port, home = homedir4(), origin, github = githubDeviceFlow()) {
24132
24424
  if (answer.includes(".")) {
24133
24425
  const hostname = normalizeHostname(answer);
24134
24426
  const tunnel = await promptForTunnelName(hostname, hostname.split(".")[0]);
@@ -24147,9 +24439,9 @@ async function establishCloudConfig(answer, port, home = homedir4(), origin, git
24147
24439
  const identity = await loadOrCreateClaimIdentity(home);
24148
24440
  let token;
24149
24441
  if (!anonymous) {
24150
- token = await github.accessToken();
24151
- const login = await github.login();
24152
- if (login) console.log(` claiming ${name}.frizz.sh for GitHub user ${login}`);
24442
+ const authorization = await github.authorize();
24443
+ token = authorization.token;
24444
+ if (authorization.login) console.log(` claiming ${name}.frizz.sh for GitHub user ${authorization.login}`);
24153
24445
  } else {
24154
24446
  console.log(" claiming a private name on frizz.sh \u2014 no account needed");
24155
24447
  }
@@ -24192,7 +24484,7 @@ async function resolveRunToken(config, port, home = homedir4(), onWarning, origi
24192
24484
  function resolveTunnelConfigPath(config, home = homedir4()) {
24193
24485
  if (config.config) return config.config;
24194
24486
  const candidate = join5(home, ".cloudflared", "frizz.yml");
24195
- return existsSync2(candidate) ? candidate : null;
24487
+ return existsSync3(candidate) ? candidate : null;
24196
24488
  }
24197
24489
  async function startRelay(config, port, home = homedir4(), onStatus) {
24198
24490
  const identity = await loadOrCreateClaimIdentity(home);
@@ -24322,29 +24614,23 @@ function createRemoteController(options2) {
24322
24614
  }
24323
24615
 
24324
24616
  // src/remote-detect.ts
24325
- import { execFile as execFile3 } from "node:child_process";
24617
+ import { execFile as execFile2 } from "node:child_process";
24326
24618
  var PROBE_TIMEOUT_MS = 4e3;
24327
- function run2(command, args2) {
24619
+ function run(command, args2) {
24328
24620
  return new Promise((resolve7) => {
24329
- execFile3(command, args2, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
24621
+ execFile2(command, args2, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
24330
24622
  resolve7(error ? null : stdout);
24331
24623
  });
24332
24624
  });
24333
24625
  }
24334
- async function probeGithub() {
24335
- const version = await run2("gh", ["--version"]);
24336
- if (version === null) return { login: null, installed: false };
24337
- const login = await run2("gh", ["api", "user", "--jq", ".login"]);
24338
- return { login: login?.trim() || null, installed: true };
24339
- }
24340
24626
  async function probeCloudflared() {
24341
- const out = await run2("cloudflared", ["--version"]);
24627
+ const out = await run("cloudflared", ["--version"]);
24342
24628
  const version = out?.match(/\d+\.\d+\.\d+/)?.[0] ?? (out ? out.trim() : null);
24343
24629
  return { version };
24344
24630
  }
24345
24631
  async function probeTailscale() {
24346
- const out = await run2("tailscale", ["status", "--json"]);
24347
- if (out === null) return { installed: await run2("tailscale", ["--version"]) !== null, dnsName: null };
24632
+ const out = await run("tailscale", ["status", "--json"]);
24633
+ if (out === null) return { installed: await run("tailscale", ["--version"]) !== null, dnsName: null };
24348
24634
  try {
24349
24635
  const status = JSON.parse(out);
24350
24636
  const dnsName = status.Self?.DNSName?.replace(/\.$/, "") || null;
@@ -24358,7 +24644,7 @@ async function probeTailscale() {
24358
24644
  init_src();
24359
24645
  var CHOICES = [
24360
24646
  { kind: "private", title: "Private name", blurb: "an unguessable name on frizz.sh \u2014 no account, nothing to install" },
24361
- { kind: "frizz", title: "Custom name", blurb: "<name>.frizz.sh of your choosing; needs the GitHub CLI" },
24647
+ { kind: "frizz", title: "Custom name", blurb: "<name>.frizz.sh of your choosing; needs a GitHub account" },
24362
24648
  { kind: "cloudflare", title: "Cloudflare Tunnel", blurb: "a domain you own on Cloudflare; cloudflared on this machine" },
24363
24649
  { kind: "tailscale", title: "Tailscale", blurb: "your tailnet; tailscale serve does the TLS" },
24364
24650
  { kind: "other", title: "Something else", blurb: "any proxy or tunnel you run \u2014 tell Frizz its address" },
@@ -24395,7 +24681,6 @@ function createRemotePane(options2) {
24395
24681
  const output = options2.output ?? process.stdout;
24396
24682
  let open = false;
24397
24683
  let screen = { name: "menu", index: 0 };
24398
- let github = null;
24399
24684
  let cloudflared = null;
24400
24685
  let tailscale = null;
24401
24686
  const write = (lines) => {
@@ -24440,6 +24725,10 @@ function createRemotePane(options2) {
24440
24725
  return;
24441
24726
  }
24442
24727
  if (s.name === "busy") {
24728
+ if (s.detail) {
24729
+ write([s.message, "", ...s.detail, "", `${DIM}waiting for GitHub\u2026 \xB7 esc cancel${RESET2}`]);
24730
+ return;
24731
+ }
24443
24732
  write([s.message, "", `${DIM}working\u2026${RESET2}`]);
24444
24733
  return;
24445
24734
  }
@@ -24469,7 +24758,6 @@ function createRemotePane(options2) {
24469
24758
  };
24470
24759
  const formHead = (kind) => {
24471
24760
  if (kind === "frizz") {
24472
- 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";
24473
24761
  return [
24474
24762
  "Custom frizz.sh name",
24475
24763
  "",
@@ -24478,7 +24766,9 @@ function createRemotePane(options2) {
24478
24766
  ),
24479
24767
  ...options2.sandbox ? ["", ...wrap("This is a sandbox, but a claim is real: it binds this machine's one name to your account, and your real board keeps it. Only the setup saved here is thrown away.")] : [],
24480
24768
  "",
24481
- gh
24769
+ ...wrap(
24770
+ "When you claim, Frizz shows a code to enter at github.com/login/device. The sign-in grants Frizz no permissions: it only tells the registrar which account you are."
24771
+ )
24482
24772
  ];
24483
24773
  }
24484
24774
  if (kind === "cloudflare") {
@@ -24528,14 +24818,6 @@ function createRemotePane(options2) {
24528
24818
  const same = kindOf(current) === kind ? current : null;
24529
24819
  let fields;
24530
24820
  if (kind === "frizz") {
24531
- github = "pending";
24532
- void options2.probes.github().then((result) => {
24533
- github = result;
24534
- if (screen.name === "form" && screen.kind === "frizz" && !screen.fields[0].value && result.login) {
24535
- screen.fields[0].placeholder = result.login;
24536
- }
24537
- paint();
24538
- });
24539
24821
  fields = [{ label: "Name", value: same?.claim ?? "" }];
24540
24822
  } else if (kind === "cloudflare") {
24541
24823
  cloudflared = "pending";
@@ -24574,7 +24856,24 @@ function createRemotePane(options2) {
24574
24856
  if (!name) throw new Error("a name is needed");
24575
24857
  screen = { name: "busy", message: `claiming ${name}.frizz.sh\u2026` };
24576
24858
  paint();
24577
- next = await options2.claim(name);
24859
+ const abort = new AbortController();
24860
+ next = await options2.claim(name, {
24861
+ signal: abort.signal,
24862
+ onDeviceCode: (prompt) => {
24863
+ screen = {
24864
+ name: "busy",
24865
+ message: `Confirm your GitHub account to claim ${name}.frizz.sh`,
24866
+ detail: [
24867
+ `Open ${prompt.verificationUri}`,
24868
+ `Enter ${prompt.userCode}`,
24869
+ "",
24870
+ ...wrap("on this or any other device. Frizz asks GitHub for no permissions; the sign-in only names your account to the registrar.")
24871
+ ],
24872
+ cancel: () => abort.abort()
24873
+ };
24874
+ paint();
24875
+ }
24876
+ });
24578
24877
  justClaimed = true;
24579
24878
  } else if (form.kind === "cloudflare") {
24580
24879
  const hostname = normalizeHostname(valueOf(form.fields[0]));
@@ -24600,7 +24899,8 @@ function createRemotePane(options2) {
24600
24899
  screen = { name: "busy", message: "claiming a private name on frizz.sh\u2026" };
24601
24900
  paint();
24602
24901
  try {
24603
- const next = await options2.claim("");
24902
+ const next = await options2.claim("", { signal: new AbortController().signal, onDeviceCode: () => {
24903
+ } });
24604
24904
  screen = { name: "busy", message: `serving ${next.hostname}\u2026` };
24605
24905
  paint();
24606
24906
  await options2.apply(next, { justClaimed: true });
@@ -24635,7 +24935,13 @@ function createRemotePane(options2) {
24635
24935
  },
24636
24936
  key(key) {
24637
24937
  const s = screen;
24638
- if (s.name === "busy") return "keep";
24938
+ if (s.name === "busy") {
24939
+ if (key === ESC && s.cancel) {
24940
+ s.cancel();
24941
+ s.cancel = void 0;
24942
+ }
24943
+ return "keep";
24944
+ }
24639
24945
  if (s.name === "done") return "close";
24640
24946
  if (s.name === "error") {
24641
24947
  screen = s.back;
@@ -25033,7 +25339,7 @@ init_boot_progress();
25033
25339
  init_frizz_paths();
25034
25340
  import { execFileSync as execFileSync3 } from "node:child_process";
25035
25341
  import { createServer as createServer2 } from "node:net";
25036
- import { existsSync as existsSync5, mkdirSync as mkdirSync10, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync11, realpathSync as realpathSync5, rmSync as rmSync8, symlinkSync as symlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
25342
+ import { existsSync as existsSync6, mkdirSync as mkdirSync10, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync10, realpathSync as realpathSync5, rmSync as rmSync8, symlinkSync as symlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
25037
25343
  import { homedir as homedir10, tmpdir as tmpdir2 } from "node:os";
25038
25344
  import { basename as basename5, dirname as dirname10, join as join13, resolve as resolve4 } from "node:path";
25039
25345
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -25187,21 +25493,21 @@ function shareCredentials(realHome, home, realEnv, sandboxEnv) {
25187
25493
  mkdirSync10(dirname10(realKey), { recursive: true });
25188
25494
  mkdirSync10(dirname10(sandboxKey), { recursive: true });
25189
25495
  link(realKey, sandboxKey);
25190
- if (existsSync5(join13(realHome, ".config", "gh"))) {
25496
+ if (existsSync6(join13(realHome, ".config", "gh"))) {
25191
25497
  mkdirSync10(join13(home, ".config"), { recursive: true });
25192
25498
  link(join13(realHome, ".config", "gh"), join13(home, ".config", "gh"));
25193
25499
  }
25194
25500
  const cloudflared = join13(realHome, ".cloudflared");
25195
- if (existsSync5(cloudflared)) {
25501
+ if (existsSync6(cloudflared)) {
25196
25502
  mkdirSync10(join13(home, ".cloudflared"), { recursive: true });
25197
25503
  for (const entry of readdirSync3(cloudflared)) {
25198
25504
  if (entry === "cert.pem" || entry.endsWith(".json")) link(join13(cloudflared, entry), join13(home, ".cloudflared", entry));
25199
25505
  }
25200
25506
  }
25201
25507
  for (const entry of [".claude", ".claude.json", ".codex"]) {
25202
- if (existsSync5(join13(realHome, entry))) link(join13(realHome, entry), join13(home, entry));
25508
+ if (existsSync6(join13(realHome, entry))) link(join13(realHome, entry), join13(home, entry));
25203
25509
  }
25204
- if (process.platform === "darwin" && existsSync5(join13(realHome, "Library", "Keychains"))) {
25510
+ if (process.platform === "darwin" && existsSync6(join13(realHome, "Library", "Keychains"))) {
25205
25511
  mkdirSync10(join13(home, "Library"), { recursive: true });
25206
25512
  link(join13(realHome, "Library", "Keychains"), join13(home, "Library", "Keychains"));
25207
25513
  }
@@ -25294,7 +25600,7 @@ function workspaceFromLaunchTarget(target2, env = process.env) {
25294
25600
  function parseStatusFile(path, authoritative, expected2, adapter = defaultProcessPlatformAdapter) {
25295
25601
  try {
25296
25602
  const value = JSON.parse(
25297
- readFileSync11(path, "utf8")
25603
+ readFileSync10(path, "utf8")
25298
25604
  );
25299
25605
  if (!Number.isInteger(value.pid) || value.pid <= 0 || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535)
25300
25606
  return null;
@@ -25336,7 +25642,7 @@ function liveWorkspaceOwner(stateDir, expected2, adapter = defaultProcessPlatfor
25336
25642
  function readPreferredPort(stateDir) {
25337
25643
  try {
25338
25644
  const value = JSON.parse(
25339
- readFileSync11(join13(stateDir, "launcher.json"), "utf8")
25645
+ readFileSync10(join13(stateDir, "launcher.json"), "utf8")
25340
25646
  );
25341
25647
  return Number.isInteger(value.port) && value.port > 0 && value.port <= 65535 ? value.port : void 0;
25342
25648
  } catch {
@@ -25572,15 +25878,16 @@ init_project_launch();
25572
25878
 
25573
25879
  // packages/server/src/dev-supervisor.ts
25574
25880
  init_project_launch();
25881
+ init_restart_supervisor();
25882
+ init_logging();
25883
+ init_boot_progress();
25575
25884
  import { fork } from "node:child_process";
25576
25885
  import { randomUUID as randomUUID5 } from "node:crypto";
25577
- import { existsSync as existsSync6, mkdirSync as mkdirSync11, readFileSync as readFileSync12 } from "node:fs";
25886
+ import { existsSync as existsSync7, mkdirSync as mkdirSync11, readFileSync as readFileSync11 } from "node:fs";
25578
25887
  import { createServer as createNetServer } from "node:net";
25579
25888
  import { basename as basename6, dirname as dirname11, extname as extname2, isAbsolute as isAbsolute4, relative as relative2, resolve as resolve5, sep as sep3 } from "node:path";
25580
25889
  import { fileURLToPath } from "node:url";
25581
25890
  import watcher from "@parcel/watcher";
25582
- init_logging();
25583
- init_boot_progress();
25584
25891
  var DEV_RESTART_DEBOUNCE_MS = 180;
25585
25892
  var DEV_CRASH_STABLE_MS = 5e3;
25586
25893
  var DEV_CRASH_RETRY_BASE_MS = 500;
@@ -25681,8 +25988,8 @@ function isWithin(path, root) {
25681
25988
  const rel = relative2(root, path);
25682
25989
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute4(rel);
25683
25990
  }
25684
- function ignoredDevPath(path) {
25685
- const parts = resolve5(path).split(sep3);
25991
+ function ignoredDevPath(relPath) {
25992
+ const parts = relPath.split(sep3);
25686
25993
  const name = parts.at(-1) ?? "";
25687
25994
  return parts.some((part) => GENERATED_DIRS.has(part) || part === "fixtures" || part.endsWith(".fixtures")) || /\.(?:test|spec)\.[^.]+$/.test(name) || name.includes(".golden.");
25688
25995
  }
@@ -25690,9 +25997,9 @@ function classifyDevChange(path, roots = defaultDevWatchRoots()) {
25690
25997
  const absolute = resolve5(path);
25691
25998
  const root = roots.find((candidate) => isWithin(absolute, resolve5(candidate)));
25692
25999
  if (!root) return null;
25693
- if (ignoredDevPath(absolute)) return null;
25694
- const name = basename6(absolute);
25695
26000
  const relToWorkspace = relative2(resolve5(root), absolute);
26001
+ if (ignoredDevPath(relToWorkspace)) return null;
26002
+ const name = basename6(absolute);
25696
26003
  const parts = relToWorkspace.split(sep3);
25697
26004
  const packageName = parts[0] === "packages" ? parts[1] : void 0;
25698
26005
  if (name === "package.json" && parts.length === 3 && packageName && CHILD_PACKAGE_METADATA.has(packageName)) {
@@ -25718,7 +26025,7 @@ function devConfigSyntaxError(path) {
25718
26025
  if (name !== "package.json" && !isTsconfig) return null;
25719
26026
  let text;
25720
26027
  try {
25721
- text = readFileSync12(path, "utf8");
26028
+ text = readFileSync11(path, "utf8");
25722
26029
  } catch (err) {
25723
26030
  return `${name}: ${err instanceof Error ? err.message : err}`;
25724
26031
  }
@@ -25896,6 +26203,7 @@ var Supervisor = class {
25896
26203
  publicOrigin: opts.publicOrigin,
25897
26204
  onCodeConsumed: opts.onCodeConsumed,
25898
26205
  sessionKey: opts.sessionKey,
26206
+ rotateSessionKey: opts.rotateSessionKey,
25899
26207
  sessionDirectory: opts.sessionDirectory,
25900
26208
  childPort: () => this.childPort,
25901
26209
  restart: () => this.restartFromBrowser(),
@@ -25941,7 +26249,7 @@ var Supervisor = class {
25941
26249
  await this.publicProxy.listen();
25942
26250
  if (this.watchEnabled) {
25943
26251
  const settled = await Promise.allSettled(
25944
- this.roots.filter(existsSync6).map((root) => this.watchSubscribe(root, (err, events) => this.onWatch(err, events), {
26252
+ this.roots.filter(existsSync7).map((root) => this.watchSubscribe(root, (err, events) => this.onWatch(err, events), {
25945
26253
  ignore: DEV_WATCH_IGNORE
25946
26254
  }))
25947
26255
  );
@@ -26000,7 +26308,7 @@ var Supervisor = class {
26000
26308
  }
26001
26309
  this.reloadLauncher ||= reloadLauncher;
26002
26310
  if (this.debounce) clearTimeout(this.debounce);
26003
- const run3 = () => {
26311
+ const run2 = () => {
26004
26312
  this.debounce = null;
26005
26313
  const scope = this.reloadLauncher ? "control plane + launcher" : "control plane";
26006
26314
  this.logLine(
@@ -26009,8 +26317,8 @@ var Supervisor = class {
26009
26317
  this.writeStatus("restarting", reason);
26010
26318
  void this.restart();
26011
26319
  };
26012
- if (immediate) run3();
26013
- else this.debounce = setTimeout(run3, delayMs);
26320
+ if (immediate) run2();
26321
+ else this.debounce = setTimeout(run2, delayMs);
26014
26322
  }
26015
26323
  async restart() {
26016
26324
  if (this.updateChildRunning) {
@@ -26602,13 +26910,13 @@ async function startDevSupervisor(opts) {
26602
26910
  }
26603
26911
 
26604
26912
  // src/production-update.ts
26605
- import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
26606
- import { existsSync as existsSync7 } from "node:fs";
26913
+ import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
26914
+ import { existsSync as existsSync8 } from "node:fs";
26607
26915
  import { dirname as dirname12, isAbsolute as isAbsolute5, join as join14 } from "node:path";
26608
26916
  init_src();
26609
26917
  var PRODUCTION_REEXEC_FLAG = "--_frizz-production-reexec";
26610
26918
  var PRODUCTION_PRINT_LAUNCHER_FLAG = "--_frizz-print-launcher";
26611
- function resolveNpmInvocation(env = process.env, execPath = process.execPath, exists = existsSync7, platform = process.platform) {
26919
+ function resolveNpmInvocation(env = process.env, execPath = process.execPath, exists = existsSync8, platform = process.platform) {
26612
26920
  const nodeDir = dirname12(execPath);
26613
26921
  const candidates = [
26614
26922
  // pnpm, yarn and bun set `npm_execpath` too, to their own entry file. Their directories hold no
@@ -26706,7 +27014,7 @@ function createNpmRegistryReleaseAdapter(options2 = {}) {
26706
27014
  } catch (error) {
26707
27015
  return reject(error);
26708
27016
  }
26709
- execFile4(
27017
+ execFile3(
26710
27018
  npm.command,
26711
27019
  [...npm.prefixArgs, "view", `${packageName}@latest`, "version", "--json"],
26712
27020
  { encoding: "utf8", windowsHide: true, timeout: viewTimeoutMs, killSignal: NPM_KILL_SIGNAL },
@@ -26774,15 +27082,15 @@ var npmRegistryReleaseAdapter = createNpmRegistryReleaseAdapter();
26774
27082
 
26775
27083
  // src/server-release.ts
26776
27084
  init_frizz_paths();
26777
- import { execFile as execFile5 } from "node:child_process";
27085
+ import { execFile as execFile4 } from "node:child_process";
26778
27086
  import { createHash as createHash4, randomUUID as randomUUID6 } from "node:crypto";
26779
27087
  import {
26780
27088
  closeSync as closeSync5,
26781
- existsSync as existsSync8,
27089
+ existsSync as existsSync9,
26782
27090
  fsyncSync as fsyncSync5,
26783
27091
  mkdirSync as mkdirSync12,
26784
27092
  openSync as openSync6,
26785
- readFileSync as readFileSync13,
27093
+ readFileSync as readFileSync12,
26786
27094
  realpathSync as realpathSync6,
26787
27095
  renameSync as renameSync6,
26788
27096
  rmSync as rmSync9,
@@ -26808,7 +27116,7 @@ function assertSpec(spec, compatibility = CURRENT_COMPATIBILITY) {
26808
27116
  throw new Error("this Frizz server requires a newer launcher or a data migration; restart with a compatible Frizz release");
26809
27117
  }
26810
27118
  function readJson(path) {
26811
- return JSON.parse(readFileSync13(path, "utf8"));
27119
+ return JSON.parse(readFileSync12(path, "utf8"));
26812
27120
  }
26813
27121
  function record(value) {
26814
27122
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid Frizz server manifest");
@@ -26873,12 +27181,12 @@ function resolveNpmCli(env = process.env, executable = process.execPath) {
26873
27181
  } catch {
26874
27182
  }
26875
27183
  }
26876
- const found = candidates.find((path) => existsSync8(path) && statSync5(path).isFile());
27184
+ const found = candidates.find((path) => existsSync9(path) && statSync5(path).isFile());
26877
27185
  if (!found) throw new Error("npm was not found beside Node or on PATH; install Node with npm to install Frizz server releases");
26878
27186
  return resolve6(found);
26879
27187
  }
26880
27188
  function npmServerPackageInstaller(env = process.env) {
26881
- const run3 = (args2, cwd) => new Promise((resolveOutput, reject) => {
27189
+ const run2 = (args2, cwd) => new Promise((resolveOutput, reject) => {
26882
27190
  let cli;
26883
27191
  try {
26884
27192
  cli = resolveNpmCli(env);
@@ -26886,7 +27194,7 @@ function npmServerPackageInstaller(env = process.env) {
26886
27194
  reject(error);
26887
27195
  return;
26888
27196
  }
26889
- execFile5(process.execPath, [cli, ...args2], {
27197
+ execFile4(process.execPath, [cli, ...args2], {
26890
27198
  env,
26891
27199
  cwd,
26892
27200
  encoding: "utf8",
@@ -26904,7 +27212,7 @@ function npmServerPackageInstaller(env = process.env) {
26904
27212
  return {
26905
27213
  async install(prefix, spec) {
26906
27214
  assertSpec(spec);
26907
- await run3([
27215
+ await run2([
26908
27216
  "install",
26909
27217
  "--prefix",
26910
27218
  prefix,
@@ -26922,7 +27230,7 @@ function npmServerPackageInstaller(env = process.env) {
26922
27230
  },
26923
27231
  async latestVersion(packageName) {
26924
27232
  if (!PACKAGE.test(packageName)) throw new Error("invalid Frizz server package name");
26925
- const parsed = JSON.parse(await run3(["view", `${packageName}@latest`, "version", "--json"]));
27233
+ const parsed = JSON.parse(await run2(["view", `${packageName}@latest`, "version", "--json"]));
26926
27234
  const version = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
26927
27235
  if (typeof version !== "string" || !VERSION.test(version)) throw new Error("npm returned an invalid Frizz server version");
26928
27236
  return version;
@@ -26975,7 +27283,7 @@ var ServerReleaseStore = class {
26975
27283
  return join15(this.generations, id, "node_modules", this.spec.package);
26976
27284
  }
26977
27285
  readCompatibilityMarker() {
26978
- if (!existsSync8(this.compatibilityMarker)) return void 0;
27286
+ if (!existsSync9(this.compatibilityMarker)) return void 0;
26979
27287
  try {
26980
27288
  const marker = record(readJson(this.compatibilityMarker));
26981
27289
  const compatibility = { protocol: marker.protocol, dataEpoch: marker.dataEpoch };
@@ -27012,7 +27320,7 @@ var ServerReleaseStore = class {
27012
27320
  }
27013
27321
  async load() {
27014
27322
  this.assertMarkerIsSupported();
27015
- if (!existsSync8(this.selection)) return this.prepareInitial(this.spec.version);
27323
+ if (!existsSync9(this.selection)) return this.prepareInitial(this.spec.version);
27016
27324
  const selected = record(readJson(this.selection));
27017
27325
  const spec = selected;
27018
27326
  assertSpecShape(spec);
@@ -27024,7 +27332,7 @@ var ServerReleaseStore = class {
27024
27332
  if (spec.dataEpoch > this.compatibility.dataEpoch)
27025
27333
  throw new Error("this Frizz server requires a newer launcher or a data migration; restart with a compatible Frizz release");
27026
27334
  const root = this.packageRoot(selected.id);
27027
- const generation = !existsSync8(root) ? await this.prepare(spec.version) : validateServerGeneration(root, spec, selected.id, this.compatibility);
27335
+ const generation = !existsSync9(root) ? await this.prepare(spec.version) : validateServerGeneration(root, spec, selected.id, this.compatibility);
27028
27336
  this.markCompatibilityBeforeLaunch();
27029
27337
  return generation;
27030
27338
  }
@@ -27059,7 +27367,7 @@ var ServerReleaseStore = class {
27059
27367
  init_frizz_paths();
27060
27368
  init_project_launch();
27061
27369
  import { randomUUID as randomUUID7 } from "node:crypto";
27062
- import { mkdirSync as mkdirSync13, readFileSync as readFileSync14, realpathSync as realpathSync7 } from "node:fs";
27370
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync13, realpathSync as realpathSync7 } from "node:fs";
27063
27371
  import { join as join16 } from "node:path";
27064
27372
  var STABLE_SERVER_OWNER_NAME = "frizz-server";
27065
27373
  var STABLE_SERVER_OWNER_PROJECT_ID = "c1fd5810-0f8a-4c1d-91a0-6d7445d28e5a";
@@ -27082,7 +27390,7 @@ function matchingTarget(owner, target2) {
27082
27390
  }
27083
27391
  function readAddress(path) {
27084
27392
  try {
27085
- const value = JSON.parse(readFileSync14(path, "utf8"));
27393
+ const value = JSON.parse(readFileSync13(path, "utf8"));
27086
27394
  if (value.version !== 1 || typeof value.ownerToken !== "string" || !UUID_RE2.test(value.ownerToken) || typeof value.publisherToken !== "string" || !UUID_RE2.test(value.publisherToken) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0 || typeof value.processStart !== "string" || value.processStart.length === 0 || typeof value.port !== "number" || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535) return null;
27087
27395
  return value;
27088
27396
  } catch {
@@ -27203,7 +27511,7 @@ init_project_identity();
27203
27511
  var PACKAGE_NAME = process.env.FRIZZ_REGISTRY_PACKAGE ?? "frizz";
27204
27512
  function resolvePackageVersion() {
27205
27513
  try {
27206
- const manifest = JSON.parse(readFileSync15(join17(import.meta.dirname, "..", "package.json"), "utf8"));
27514
+ const manifest = JSON.parse(readFileSync14(join17(import.meta.dirname, "..", "package.json"), "utf8"));
27207
27515
  if (typeof manifest.version === "string" && manifest.version.trim()) return manifest.version.trim();
27208
27516
  } catch {
27209
27517
  }
@@ -27270,6 +27578,7 @@ var paneHost = null;
27270
27578
  var globalInstall = process.env[GLOBAL_INSTALL_ENV] === "1" || isGlobalInstall(import.meta.dirname);
27271
27579
  var accessPane = null;
27272
27580
  var activeAccessLink = null;
27581
+ var sessionNotice = null;
27273
27582
  var serverOwner;
27274
27583
  process.on("exit", () => {
27275
27584
  if (serverOwner) releaseStableServerOwner(serverOwner);
@@ -27434,6 +27743,7 @@ async function openOrPrint(port, reused, path = "") {
27434
27743
  const publicOrigin = reused ? void 0 : remote?.origin();
27435
27744
  const warnings = [];
27436
27745
  if (sandbox) warnings.push(`Sandbox: everything here is throwaway (${sandbox.home}) and is deleted when this terminal closes.`);
27746
+ if (sessionNotice && !reused) warnings.push(sessionNotice);
27437
27747
  if (!readout) {
27438
27748
  console.log(`${reused ? "reusing" : "started"} Frizz for ${workspace.root}`);
27439
27749
  console.log(url);
@@ -27477,7 +27787,7 @@ async function runSupervisor(port, token, onPrepared = () => {
27477
27787
  owner.token
27478
27788
  );
27479
27789
  const spec = serverReleaseSpec(
27480
- JSON.parse(readFileSync15(join17(import.meta.dirname, "..", "package.json"), "utf8")),
27790
+ JSON.parse(readFileSync14(join17(import.meta.dirname, "..", "package.json"), "utf8")),
27481
27791
  process.env.FRIZZ_SERVER_PACKAGE
27482
27792
  );
27483
27793
  const installer = npmServerPackageInstaller(env);
@@ -27502,17 +27812,19 @@ async function runSupervisor(port, token, onPrepared = () => {
27502
27812
  void refreshUpdateAvailable();
27503
27813
  const updateAvailablePoll = setInterval(() => void refreshUpdateAvailable(), 30 * 60 * 1e3);
27504
27814
  updateAvailablePoll.unref();
27815
+ const sessions = loadSessionState(workspace.stateDir);
27816
+ const epochRecord = sessionEpochLogLine(sessions.advance, sessions.signedOut);
27817
+ if (epochRecord) logger.warn("launcher", epochRecord);
27818
+ sessionNotice = sessionEpochNotice(sessions.signedOut);
27505
27819
  const supervisor = await startDevSupervisor({
27506
27820
  port,
27507
27821
  host: LOOPBACK_BIND_HOST,
27508
27822
  allowedHosts: [],
27509
27823
  // A spent code repaints the open QR pane as stale, so nobody photographs a dead link.
27510
27824
  onCodeConsumed: () => accessPane?.markConsumed(),
27511
- // Persisted beside the project's other state, so a restart does not sign every device out.
27512
- // Always, not only when public: the origin can be switched on later (press R), and a phone signed
27513
- // in through one name stays signed in through the next.
27514
- sessionKey: loadOrCreateSessionKey(workspace.stateDir),
27515
- sessionDirectory: fileSessionDirectory(workspace.stateDir),
27825
+ sessionKey: sessions.key,
27826
+ rotateSessionKey: sessions.rotateKey,
27827
+ sessionDirectory: sessions.directory,
27516
27828
  cwd: workspace.root,
27517
27829
  stateDir: workspace.stateDir,
27518
27830
  launchTarget: target,
@@ -27578,9 +27890,10 @@ async function runSupervisor(port, token, onPrepared = () => {
27578
27890
  port,
27579
27891
  current: () => remote?.current() ?? null,
27580
27892
  apply: (next, applyOptions) => remote.apply(next, applyOptions),
27581
- claim: (name) => establishCloudConfig(name, port),
27893
+ // The pane shows the GitHub device code itself: a console line would land under its alternate screen.
27894
+ claim: (name, signIn) => establishCloudConfig(name, port, void 0, void 0, githubDeviceFlow({ onPrompt: signIn.onDeviceCode, signal: signIn.signal })),
27582
27895
  issueLink: () => supervisor.issueAccessLink(),
27583
- probes: { github: probeGithub, cloudflared: probeCloudflared, tailscale: probeTailscale },
27896
+ probes: { cloudflared: probeCloudflared, tailscale: probeTailscale },
27584
27897
  onChanged: (config) => logger.info("remote", config ? `reached at https://${config.hostname}` : "loopback only"),
27585
27898
  sandbox: sandbox !== null
27586
27899
  });