frogoe 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -25,7 +25,8 @@ var init_templates = __esm({
25
25
  ].join("\n");
26
26
  briefTemplate = `---
27
27
  title: My Game
28
- verb: tap # tap | hold | steer | aim \u2014 ONE word, the core action
28
+ verb: tap # tap|hold|steer|aim|swap|place|type|draw|idle \u2014 ONE word, the core action
29
+ session: blitz # blitz (default) | round | toy \u2014 how the run ends
29
30
  mood: TODO \u2014 one phrase: "dawn uplift", "arcade cabinet at midnight"...
30
31
  palette:
31
32
  bg: "#101418" # TODO
@@ -384,7 +385,7 @@ var init_add2 = __esm({
384
385
  });
385
386
 
386
387
  // src/fetch-policy.ts
387
- var DEFAULT_FETCH_POLICY, FetchUnavailableError, FetchNotServedError, sleep, fetchWithPolicy;
388
+ var DEFAULT_FETCH_POLICY, FetchUnavailableError, FetchNotServedError, sleep, fetchWithPolicy, fetchBufferWithPolicy;
388
389
  var init_fetch_policy = __esm({
389
390
  "src/fetch-policy.ts"() {
390
391
  "use strict";
@@ -444,7 +445,50 @@ var init_fetch_policy = __esm({
444
445
  } catch (error) {
445
446
  const retryable = error instanceof FetchUnavailableError || !(error instanceof FetchNotServedError);
446
447
  if (!retryable || attempt === policy.maxAttempts) {
447
- throw error;
448
+ if (error instanceof FetchUnavailableError || error instanceof FetchNotServedError) {
449
+ throw error;
450
+ }
451
+ throw new FetchUnavailableError(url, error);
452
+ }
453
+ await sleep(Math.random() * policy.baseDelayMs * 2 ** (attempt - 1));
454
+ } finally {
455
+ clearTimeout(timer);
456
+ }
457
+ }
458
+ throw new FetchUnavailableError(url, new Error("unreachable"));
459
+ };
460
+ fetchBufferWithPolicy = async (url, options) => {
461
+ const policy = { ...DEFAULT_FETCH_POLICY, ...options?.policy };
462
+ const doFetch = options?.fetchImpl ?? fetch;
463
+ const started = Date.now();
464
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
465
+ if (Date.now() - started > policy.maxElapsedMs) {
466
+ throw new FetchUnavailableError(url, new Error("wall-clock budget exceeded"));
467
+ }
468
+ const controller = new AbortController();
469
+ const timer = setTimeout(() => {
470
+ controller.abort();
471
+ }, policy.attemptTimeoutMs);
472
+ try {
473
+ const res = await doFetch(url, {
474
+ headers: options?.headers,
475
+ redirect: "follow",
476
+ signal: controller.signal
477
+ });
478
+ if (res.status >= 500) {
479
+ throw new FetchUnavailableError(url, new Error(`upstream ${res.status}`));
480
+ }
481
+ if (res.status >= 400) {
482
+ throw new FetchNotServedError(url, res.status);
483
+ }
484
+ return Buffer.from(await res.arrayBuffer());
485
+ } catch (error) {
486
+ const retryable = error instanceof FetchUnavailableError || !(error instanceof FetchNotServedError);
487
+ if (!retryable || attempt === policy.maxAttempts) {
488
+ if (error instanceof FetchUnavailableError || error instanceof FetchNotServedError) {
489
+ throw error;
490
+ }
491
+ throw new FetchUnavailableError(url, error);
448
492
  }
449
493
  await sleep(Math.random() * policy.baseDelayMs * 2 ** (attempt - 1));
450
494
  } finally {
@@ -456,15 +500,109 @@ var init_fetch_policy = __esm({
456
500
  }
457
501
  });
458
502
 
459
- // src/bundle.ts
503
+ // src/net/font-proxy.ts
460
504
  import { createHash } from "crypto";
461
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
505
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
462
506
  import path3 from "path";
463
- var ALLOWED_HOSTS, PIN_EXEMPT_HOSTS, FONT_UA, MIME, sha256, dataUri, assertAllowedRemote, cdnPlugin, importMapPlugin, inlineFontCss, localDataUri, LEAK_PATTERN, bundle;
507
+ var ALLOWED_FONT_HOSTS, FONT_UA, UPSTREAM_TIMEOUT_MS, FONT_LINK_PATTERN, CSS_URL_PATTERN, proxyPathFor, PROXY_PATH_PREFIX, isLegacyCachedCss, rewriteFontLinks, allowedUrl, decodeProxyToken, rewriteCssFontRefs, typeFor, cacheFileFor, isBinaryEntry, CACHE_MAGIC, readCachedFont, writeCachedFont, proxyFontRequest;
508
+ var init_font_proxy = __esm({
509
+ "src/net/font-proxy.ts"() {
510
+ "use strict";
511
+ ALLOWED_FONT_HOSTS = /* @__PURE__ */ new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
512
+ FONT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
513
+ UPSTREAM_TIMEOUT_MS = 4e3;
514
+ FONT_LINK_PATTERN = /href="(https:\/\/(?:fonts\.googleapis\.com|fonts\.gstatic\.com)\/[^"]+)"/gu;
515
+ CSS_URL_PATTERN = /url\((https:\/\/[^)]+)\)/gu;
516
+ proxyPathFor = (url) => `/__frogoe/font/${Buffer.from(url, "utf-8").toString("base64url")}`;
517
+ PROXY_PATH_PREFIX = "/__frogoe/font/";
518
+ isLegacyCachedCss = (css) => css.includes(PROXY_PATH_PREFIX);
519
+ rewriteFontLinks = (html) => html.replaceAll(FONT_LINK_PATTERN, (_match, url) => `href="${proxyPathFor(url)}"`);
520
+ allowedUrl = (url) => {
521
+ try {
522
+ const parsed = new URL(url);
523
+ return parsed.protocol === "https:" && ALLOWED_FONT_HOSTS.has(parsed.hostname);
524
+ } catch {
525
+ return false;
526
+ }
527
+ };
528
+ decodeProxyToken = (token) => {
529
+ const url = Buffer.from(token, "base64url").toString("utf-8");
530
+ return allowedUrl(url) ? url : null;
531
+ };
532
+ rewriteCssFontRefs = (css) => css.replaceAll(
533
+ CSS_URL_PATTERN,
534
+ (match, url) => allowedUrl(url) ? `url(${proxyPathFor(url)})` : match
535
+ );
536
+ typeFor = (file) => file.endsWith(".css") ? "text/css; charset=utf-8" : "font/woff2";
537
+ cacheFileFor = (cacheDir, url) => {
538
+ const ext = url.includes("css2?") || url.endsWith(".css") ? ".css" : ".woff2";
539
+ return path3.join(cacheDir, createHash("sha256").update(url).digest("hex") + ext);
540
+ };
541
+ isBinaryEntry = (file) => file.endsWith(".woff2");
542
+ CACHE_MAGIC = Buffer.from("FROGOEF1");
543
+ readCachedFont = (url, cacheDir) => {
544
+ const file = cacheFileFor(cacheDir, url);
545
+ if (!existsSync3(file)) return null;
546
+ const raw = readFileSync3(file);
547
+ if (isBinaryEntry(file)) {
548
+ if (raw.length < CACHE_MAGIC.length || !raw.subarray(0, CACHE_MAGIC.length).equals(CACHE_MAGIC)) {
549
+ return null;
550
+ }
551
+ return raw.subarray(CACHE_MAGIC.length);
552
+ }
553
+ return raw;
554
+ };
555
+ writeCachedFont = (url, cacheDir, body) => {
556
+ const file = cacheFileFor(cacheDir, url);
557
+ mkdirSync3(cacheDir, { recursive: true });
558
+ writeFileSync3(file, isBinaryEntry(file) ? Buffer.concat([CACHE_MAGIC, Buffer.from(body)]) : body);
559
+ return file;
560
+ };
561
+ proxyFontRequest = async (url, cacheDir, fetchImpl = fetch) => {
562
+ if (!allowedUrl(url)) {
563
+ return { body: "", contentType: "text/plain; charset=utf-8", fromCache: false, status: 403 };
564
+ }
565
+ const cachePath = cacheFileFor(cacheDir, url);
566
+ const isCss = cachePath.endsWith(".css");
567
+ const raw = readCachedFont(url, cacheDir);
568
+ if (raw !== null && !(isCss && isLegacyCachedCss(raw.toString("utf-8")))) {
569
+ const body = isCss ? rewriteCssFontRefs(raw.toString("utf-8")) : raw;
570
+ return { body, cachePath, contentType: typeFor(cachePath), fromCache: true, status: 200 };
571
+ }
572
+ try {
573
+ const res = await fetchImpl(url, {
574
+ headers: { "user-agent": FONT_UA },
575
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
576
+ });
577
+ if (!res.ok) {
578
+ return {
579
+ body: "",
580
+ contentType: typeFor(cachePath),
581
+ fromCache: false,
582
+ status: 502
583
+ };
584
+ }
585
+ const rawBody = Buffer.from(await res.arrayBuffer());
586
+ writeCachedFont(url, cacheDir, rawBody);
587
+ const body = isCss ? rewriteCssFontRefs(rawBody.toString("utf-8")) : rawBody;
588
+ return { body, cachePath, contentType: typeFor(cachePath), fromCache: false, status: 200 };
589
+ } catch {
590
+ return { body: "", contentType: typeFor(cachePath), fromCache: false, status: 504 };
591
+ }
592
+ };
593
+ }
594
+ });
595
+
596
+ // src/bundle.ts
597
+ import { createHash as createHash2 } from "crypto";
598
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
599
+ import path4 from "path";
600
+ var ALLOWED_HOSTS, PIN_EXEMPT_HOSTS, MIME, sha256, dataUri, assertAllowedRemote, cdnPlugin, importMapPlugin, inlineFontCss, localDataUri, LEAK_PATTERN, bundle;
464
601
  var init_bundle = __esm({
465
602
  "src/bundle.ts"() {
466
603
  "use strict";
467
604
  init_fetch_policy();
605
+ init_font_proxy();
468
606
  ALLOWED_HOSTS = /* @__PURE__ */ new Set([
469
607
  "cdn.jsdelivr.net",
470
608
  "esm.sh",
@@ -478,7 +616,6 @@ var init_bundle = __esm({
478
616
  "fonts.googleapis.test",
479
617
  "fonts.gstatic.test"
480
618
  ]);
481
- FONT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
482
619
  MIME = {
483
620
  gif: "image/gif",
484
621
  jpeg: "image/jpeg",
@@ -491,7 +628,7 @@ var init_bundle = __esm({
491
628
  webp: "image/webp",
492
629
  woff2: "font/woff2"
493
630
  };
494
- sha256 = (data) => createHash("sha256").update(data).digest("hex");
631
+ sha256 = (data) => createHash2("sha256").update(data).digest("hex");
495
632
  dataUri = (mime, body) => {
496
633
  const base64 = Buffer.from(body, "binary").toString("base64");
497
634
  return `data:${mime};base64,${base64}`;
@@ -551,16 +688,34 @@ var init_bundle = __esm({
551
688
  if (/^https?:\/\//.test(target)) {
552
689
  return { namespace: "frogoe-cdn", path: target };
553
690
  }
554
- return { path: path3.resolve(dir, target) };
691
+ return { path: path4.resolve(dir, target) };
555
692
  });
556
693
  }
557
694
  }
558
695
  });
559
696
  inlineFontCss = async (cssUrl, options, assets) => {
560
- const css = await fetchWithPolicy(cssUrl, {
561
- fetchImpl: options.fetchImpl,
562
- headers: { "user-agent": FONT_UA }
563
- });
697
+ const cacheDir = path4.join(path4.resolve(options.dir), ".frogoe", "font-cache");
698
+ const fontFetchError = (url, cause) => new Error(
699
+ `bundle/font-unreachable \u2014 ${url} (${cause instanceof Error ? cause.message : String(cause)})
700
+ No local cache for it and the network failed \u2014 retry when the network recovers,
701
+ or run \`frogoe check <game>\` once first: it warms the shared font cache the bundler reads.`,
702
+ { cause: cause instanceof Error ? cause : void 0 }
703
+ );
704
+ let css;
705
+ const cachedCss = readCachedFont(cssUrl, cacheDir);
706
+ if (cachedCss !== null) {
707
+ css = cachedCss.toString("utf-8");
708
+ } else {
709
+ try {
710
+ css = await fetchWithPolicy(cssUrl, {
711
+ fetchImpl: options.fetchImpl,
712
+ headers: { "user-agent": FONT_UA }
713
+ });
714
+ } catch (error) {
715
+ throw fontFetchError(cssUrl, error);
716
+ }
717
+ writeCachedFont(cssUrl, cacheDir, css);
718
+ }
564
719
  assets.push({
565
720
  bytes: css.length,
566
721
  kind: "css",
@@ -574,8 +729,21 @@ var init_bundle = __esm({
574
729
  return cached2;
575
730
  }
576
731
  assertAllowedRemote(url, options.extraAllowedHosts);
577
- const res = options.fetchImpl ? await options.fetchImpl(url, { headers: { "user-agent": FONT_UA } }) : await fetch(url, { headers: { "user-agent": FONT_UA } });
578
- const buffer = Buffer.from(await res.arrayBuffer());
732
+ let buffer;
733
+ const raw2 = readCachedFont(url, cacheDir);
734
+ if (raw2 !== null) {
735
+ buffer = raw2;
736
+ } else {
737
+ try {
738
+ buffer = await fetchBufferWithPolicy(url, {
739
+ fetchImpl: options.fetchImpl,
740
+ headers: { "user-agent": FONT_UA }
741
+ });
742
+ writeCachedFont(url, cacheDir, buffer);
743
+ } catch (error) {
744
+ throw fontFetchError(url, error);
745
+ }
746
+ }
579
747
  const uri = `data:font/woff2;base64,${buffer.toString("base64")}`;
580
748
  seen.set(url, uri);
581
749
  assets.push({
@@ -586,6 +754,18 @@ var init_bundle = __esm({
586
754
  });
587
755
  return uri;
588
756
  };
757
+ let raw = css;
758
+ for (const match of css.matchAll(/url\((\/__frogoe\/font\/[A-Za-z0-9_-]+)\)/gu)) {
759
+ const ref = match[1] ?? "";
760
+ const decoded = ref === "" ? null : decodeProxyToken(ref.slice(PROXY_PATH_PREFIX.length));
761
+ if (decoded !== null) {
762
+ raw = raw.replaceAll(ref, decoded);
763
+ }
764
+ }
765
+ if (raw !== css) {
766
+ writeCachedFont(cssUrl, cacheDir, raw);
767
+ css = raw;
768
+ }
589
769
  let out = css;
590
770
  const urls = [...css.matchAll(/url\((https:\/\/[^)]+)\)/gu)].map((m) => m[1] ?? "");
591
771
  for (const url of urls) {
@@ -596,25 +776,25 @@ var init_bundle = __esm({
596
776
  return out;
597
777
  };
598
778
  localDataUri = (dir, href) => {
599
- const file = path3.resolve(dir, href.replace(/^\.\//u, ""));
600
- if (!existsSync3(file)) {
779
+ const file = path4.resolve(dir, href.replace(/^\.\//u, ""));
780
+ if (!existsSync4(file)) {
601
781
  throw new Error(`bundle/missing-asset: ${href} not found in the game folder`);
602
782
  }
603
- const ext = path3.extname(file).slice(1).toLowerCase();
783
+ const ext = path4.extname(file).slice(1).toLowerCase();
604
784
  const mime = MIME[ext];
605
785
  if (!mime) {
606
786
  throw new Error(`bundle/unsupported-asset: .${ext} (${href})`);
607
787
  }
608
- return dataUri(mime, readFileSync3(file, "binary"));
788
+ return dataUri(mime, readFileSync4(file, "binary"));
609
789
  };
610
790
  LEAK_PATTERN = /(?:src|href)\s*=\s*"(https?:\/\/[^"]+)"/gi;
611
791
  bundle = async (options) => {
612
- const dir = path3.resolve(options.dir);
613
- const indexPath = path3.join(dir, "index.html");
614
- if (!existsSync3(indexPath)) {
792
+ const dir = path4.resolve(options.dir);
793
+ const indexPath = path4.join(dir, "index.html");
794
+ if (!existsSync4(indexPath)) {
615
795
  throw new Error("frogoe bundle: no index.html \u2014 run from a game folder");
616
796
  }
617
- const html = readFileSync3(indexPath, "utf-8");
797
+ const html = readFileSync4(indexPath, "utf-8");
618
798
  const assets = [];
619
799
  const warnings = [];
620
800
  let out = html;
@@ -633,7 +813,7 @@ var init_bundle = __esm({
633
813
  const { build } = await import("esbuild");
634
814
  const result = await build({
635
815
  bundle: true,
636
- entryPoints: [path3.resolve(dir, entryMatch[1])],
816
+ entryPoints: [path4.resolve(dir, entryMatch[1])],
637
817
  format: "esm",
638
818
  logLevel: "silent",
639
819
  plugins: [importMapPlugin(dir, imports), cdnPlugin(options, assets)],
@@ -661,7 +841,7 @@ ${bundled}
661
841
  ${css}
662
842
  </style>`;
663
843
  } else {
664
- const css = readFileSync3(path3.resolve(dir, href), "utf-8");
844
+ const css = readFileSync4(path4.resolve(dir, href), "utf-8");
665
845
  replacement = `<style>
666
846
  ${css}
667
847
  </style>`;
@@ -676,7 +856,7 @@ ${css}
676
856
  if (href.endsWith(".js") && entryMatch?.[1] === href) {
677
857
  continue;
678
858
  }
679
- if (existsSync3(path3.resolve(dir, href))) {
859
+ if (existsSync4(path4.resolve(dir, href))) {
680
860
  out = out.replaceAll(href, localDataUri(dir, href));
681
861
  }
682
862
  }
@@ -708,10 +888,22 @@ ${css}
708
888
  });
709
889
 
710
890
  // ../lint/src/brief.ts
711
- var KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
891
+ var VERBS, SESSIONS, KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
712
892
  var init_brief = __esm({
713
893
  "../lint/src/brief.ts"() {
714
894
  "use strict";
895
+ VERBS = [
896
+ "aim",
897
+ "draw",
898
+ "hold",
899
+ "idle",
900
+ "place",
901
+ "steer",
902
+ "swap",
903
+ "tap",
904
+ "type"
905
+ ];
906
+ SESSIONS = ["blitz", "round", "toy"];
715
907
  KEY_PATTERN = /^[a-z-]+$/u;
716
908
  WS = /\s/u;
717
909
  stripComment = (value) => {
@@ -767,8 +959,8 @@ var init_brief = __esm({
767
959
  });
768
960
 
769
961
  // ../lint/src/art.ts
770
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
771
- import path4 from "path";
962
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
963
+ import path5 from "path";
772
964
  var SPECS, MIN_DRAW_CALLS, DRAW_CALL, SPRITE_CALL, checkArt, checkScene, hexColorsOf, stripComments;
773
965
  var init_art = __esm({
774
966
  "../lint/src/art.ts"() {
@@ -782,16 +974,16 @@ var init_art = __esm({
782
974
  DRAW_CALL = /(?:fillRect|strokeRect|clearRect|beginPath|closePath|arc|ellipse|moveTo|lineTo|quadraticCurveTo|bezierCurveTo|fillText|strokeText|drawImage|roundRect|fill|stroke)\s*\(/gu;
783
975
  SPRITE_CALL = /\bdraw[A-Z]\w*\s*\(/gu;
784
976
  checkArt = (dir, findings) => {
785
- const briefPath = path4.join(dir, "BRIEF.md");
786
- const brief = existsSync4(briefPath) ? parseBrief(readFileSync4(briefPath, "utf-8")) : void 0;
977
+ const briefPath = path5.join(dir, "BRIEF.md");
978
+ const brief = existsSync5(briefPath) ? parseBrief(readFileSync5(briefPath, "utf-8")) : void 0;
787
979
  const briefFonts = (brief?.fonts ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token.length > 0);
788
- const gamePath = path4.join(dir, "game.js");
789
- const gameSource = existsSync4(gamePath) ? readFileSync4(gamePath, "utf-8") : "";
980
+ const gamePath = path5.join(dir, "game.js");
981
+ const gameSource = existsSync5(gamePath) ? readFileSync5(gamePath, "utf-8") : "";
790
982
  const gameColors = hexColorsOf(stripComments(gameSource));
791
- const htmlSource = existsSync4(path4.join(dir, "index.html")) ? readFileSync4(path4.join(dir, "index.html"), "utf-8") : "";
983
+ const htmlSource = existsSync5(path5.join(dir, "index.html")) ? readFileSync5(path5.join(dir, "index.html"), "utf-8") : "";
792
984
  for (const spec of SPECS) {
793
- const scenePath = path4.join(dir, spec.file);
794
- if (!existsSync4(scenePath)) {
985
+ const scenePath = path5.join(dir, spec.file);
986
+ if (!existsSync5(scenePath)) {
795
987
  findings.push({
796
988
  code: "art/missing",
797
989
  file: spec.file,
@@ -802,7 +994,7 @@ var init_art = __esm({
802
994
  });
803
995
  continue;
804
996
  }
805
- const source = stripComments(readFileSync4(scenePath, "utf-8"));
997
+ const source = stripComments(readFileSync5(scenePath, "utf-8"));
806
998
  checkScene(source, spec, gameColors, briefFonts, htmlSource, findings);
807
999
  }
808
1000
  };
@@ -963,19 +1155,47 @@ var init_contrast = __esm({
963
1155
  });
964
1156
 
965
1157
  // ../lint/src/check.ts
966
- import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync5 } from "fs";
967
- import path5 from "path";
968
- var VERBS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
1158
+ import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync6 } from "fs";
1159
+ import path6 from "path";
1160
+ var HANDLER_PATTERNS, VERB_REQUIREMENTS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
969
1161
  var init_check = __esm({
970
1162
  "../lint/src/check.ts"() {
971
1163
  "use strict";
972
1164
  init_brief();
973
1165
  init_contrast();
974
1166
  init_art();
975
- VERBS = /* @__PURE__ */ new Set(["tap", "hold", "steer", "aim"]);
1167
+ HANDLER_PATTERNS = {
1168
+ down: /input\.on\(\s*["']down["']/u,
1169
+ drag: /input\.on\(\s*["']drag["']/u,
1170
+ up: /input\.on\(\s*["']up["']/u
1171
+ };
1172
+ VERB_REQUIREMENTS = {
1173
+ aim: [
1174
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1175
+ { label: 'input.on("drag"', pattern: HANDLER_PATTERNS.drag }
1176
+ ],
1177
+ draw: [
1178
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1179
+ { label: 'input.on("drag"', pattern: HANDLER_PATTERNS.drag },
1180
+ { label: 'input.on("up"', pattern: HANDLER_PATTERNS.up }
1181
+ ],
1182
+ hold: [
1183
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1184
+ { label: 'input.on("up"', pattern: HANDLER_PATTERNS.up }
1185
+ ],
1186
+ idle: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1187
+ place: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1188
+ steer: [
1189
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1190
+ { label: 'input.on("drag"', pattern: HANDLER_PATTERNS.drag }
1191
+ ],
1192
+ swap: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1193
+ tap: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1194
+ type: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }]
1195
+ };
976
1196
  read = (file) => {
977
1197
  try {
978
- return readFileSync5(file, "utf-8");
1198
+ return readFileSync6(file, "utf-8");
979
1199
  } catch {
980
1200
  return "";
981
1201
  }
@@ -986,8 +1206,8 @@ var init_check = __esm({
986
1206
  return idx === -1 ? void 0 : idx + 1;
987
1207
  };
988
1208
  checkBrief = (dir, findings) => {
989
- const file = path5.join(dir, "BRIEF.md");
990
- if (!existsSync5(file)) {
1209
+ const file = path6.join(dir, "BRIEF.md");
1210
+ if (!existsSync6(file)) {
991
1211
  findings.push({
992
1212
  code: "brief/missing",
993
1213
  file: "BRIEF.md",
@@ -995,7 +1215,7 @@ var init_check = __esm({
995
1215
  message: "feed games declare intent before code",
996
1216
  severity: "error"
997
1217
  });
998
- return;
1218
+ return null;
999
1219
  }
1000
1220
  const source = read(file);
1001
1221
  const todoLine = findLine(source, /TODO/u);
@@ -1018,14 +1238,32 @@ var init_check = __esm({
1018
1238
  message: "no frontmatter block found",
1019
1239
  severity: "error"
1020
1240
  });
1021
- return;
1241
+ return null;
1242
+ }
1243
+ if (brief.verb !== void 0 && !VERBS.includes(brief.verb)) {
1244
+ findings.push({
1245
+ code: "brief/verb",
1246
+ file: "BRIEF.md",
1247
+ fix: `verb "${brief.verb}" is not in the enum (${VERBS.join("|")}) \u2014 pick the ONE word that names the core action (frogoe-core \u2192 brief-format)`,
1248
+ message: "unknown core verb",
1249
+ severity: "error"
1250
+ });
1251
+ }
1252
+ if (brief.session !== void 0 && !SESSIONS.includes(brief.session)) {
1253
+ findings.push({
1254
+ code: "brief/session",
1255
+ file: "BRIEF.md",
1256
+ fix: `session "${brief.session}" is not in the enum (${SESSIONS.join("|")}) \u2014 blitz is the short arcade loop (default), round is turn-based, toy never ends (frogoe-core \u2192 brief-format)`,
1257
+ message: "unknown session shape",
1258
+ severity: "error"
1259
+ });
1022
1260
  }
1023
1261
  const problems = [];
1024
1262
  if (!brief.title || brief.title.length < 2 || brief.title.length > 40) {
1025
1263
  problems.push("title (2\u201340 chars)");
1026
1264
  }
1027
- if (!brief.verb || !VERBS.has(brief.verb)) {
1028
- problems.push("verb \u2208 tap|hold|steer|aim");
1265
+ if (!brief.verb || !VERBS.includes(brief.verb)) {
1266
+ problems.push(`verb (${VERBS.join("|")})`);
1029
1267
  }
1030
1268
  if (!brief.mood) {
1031
1269
  problems.push("mood (one phrase)");
@@ -1068,9 +1306,10 @@ var init_check = __esm({
1068
1306
  });
1069
1307
  }
1070
1308
  }
1309
+ return brief;
1071
1310
  };
1072
- checkFolder = (dir, findings) => {
1073
- const index = read(path5.join(dir, "index.html"));
1311
+ checkFolder = (dir, findings, brief) => {
1312
+ const index = read(path6.join(dir, "index.html"));
1074
1313
  if (!index) {
1075
1314
  findings.push({
1076
1315
  code: "folder/index-missing",
@@ -1140,7 +1379,7 @@ var init_check = __esm({
1140
1379
  });
1141
1380
  }
1142
1381
  }
1143
- const game = read(path5.join(dir, "game.js"));
1382
+ const game = read(path6.join(dir, "game.js"));
1144
1383
  if (!game) {
1145
1384
  findings.push({
1146
1385
  code: "folder/game-missing",
@@ -1169,6 +1408,21 @@ var init_check = __esm({
1169
1408
  severity: "warning"
1170
1409
  });
1171
1410
  }
1411
+ const requirements = brief?.verb !== void 0 ? VERB_REQUIREMENTS[brief.verb] : void 0;
1412
+ if (requirements !== void 0) {
1413
+ const missing = requirements.filter((req) => !req.pattern.test(game));
1414
+ if (missing.length > 0) {
1415
+ findings.push({
1416
+ code: "input/verb-mismatch",
1417
+ file: "game.js",
1418
+ fix: `verb "${String(brief?.verb)}" requires ${missing.map((m) => m.label).join(" + ")} \u2014 wire it inside defineGame (frogoe-core \u2192 brief-format, verb table)`,
1419
+ line: findLine(game, HANDLER_PATTERNS.down) ?? void 0,
1420
+ message: `declared verb "${String(brief?.verb)}" but its input wiring is missing`,
1421
+ recipe: "frogoe-core \u2192 brief-format (verb \u2192 handler table)",
1422
+ severity: "error"
1423
+ });
1424
+ }
1425
+ }
1172
1426
  const gameLine = (pattern) => findLine(game, pattern);
1173
1427
  const dragLine = gameLine(/\+=\s*(?:p|pt|pointer)\.(?:dx|dy)\b/u);
1174
1428
  if (dragLine !== void 0) {
@@ -1218,12 +1472,12 @@ var init_check = __esm({
1218
1472
  severity: "warning"
1219
1473
  });
1220
1474
  }
1221
- const blocksDir = path5.join(dir, "blocks");
1475
+ const blocksDir = path6.join(dir, "blocks");
1222
1476
  let markup = index;
1223
- if (existsSync5(blocksDir)) {
1477
+ if (existsSync6(blocksDir)) {
1224
1478
  for (const f of readdirSync(blocksDir)) {
1225
1479
  if (f.endsWith(".html")) {
1226
- markup += read(path5.join(blocksDir, f));
1480
+ markup += read(path6.join(blocksDir, f));
1227
1481
  }
1228
1482
  }
1229
1483
  }
@@ -1243,8 +1497,8 @@ var init_check = __esm({
1243
1497
  }
1244
1498
  };
1245
1499
  checkPin = (dir, findings) => {
1246
- const pinFile = path5.join(dir, "frogoe.json");
1247
- if (!existsSync5(pinFile)) {
1500
+ const pinFile = path6.join(dir, "frogoe.json");
1501
+ if (!existsSync6(pinFile)) {
1248
1502
  findings.push({
1249
1503
  code: "folder/contract-pin",
1250
1504
  file: "frogoe.json",
@@ -1259,7 +1513,7 @@ var init_check = __esm({
1259
1513
  pin = String(JSON.parse(read(pinFile)).contract ?? "");
1260
1514
  } catch {
1261
1515
  }
1262
- const contract = read(path5.join(dir, ".frogoe/contract.js"));
1516
+ const contract = read(path6.join(dir, ".frogoe/contract.js"));
1263
1517
  const marker = /frogoe contract v([\d.]+)/u.exec(contract.slice(0, 400));
1264
1518
  if (!contract) {
1265
1519
  findings.push({
@@ -1282,8 +1536,8 @@ var init_check = __esm({
1282
1536
  checkProject = (dir) => {
1283
1537
  const findings = [];
1284
1538
  checkArt(dir, findings);
1285
- checkBrief(dir, findings);
1286
- checkFolder(dir, findings);
1539
+ const brief = checkBrief(dir, findings);
1540
+ checkFolder(dir, findings, brief);
1287
1541
  checkPin(dir, findings);
1288
1542
  findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
1289
1543
  return {
@@ -1307,28 +1561,28 @@ var init_src = __esm({
1307
1561
  });
1308
1562
 
1309
1563
  // src/runtime-source.ts
1310
- import { readFileSync as readFileSync6 } from "fs";
1311
- import path6 from "path";
1564
+ import { readFileSync as readFileSync7 } from "fs";
1565
+ import path7 from "path";
1312
1566
  import { fileURLToPath as fileURLToPath2 } from "url";
1313
1567
  var here2, cached, runtimeSource, functions, runtimeFunctions;
1314
1568
  var init_runtime_source = __esm({
1315
1569
  "src/runtime-source.ts"() {
1316
1570
  "use strict";
1317
- here2 = path6.dirname(fileURLToPath2(import.meta.url));
1571
+ here2 = path7.dirname(fileURLToPath2(import.meta.url));
1318
1572
  cached = null;
1319
1573
  runtimeSource = () => {
1320
1574
  if (cached !== null) return cached;
1321
1575
  const candidates = [
1322
- path6.join(here2, "injected-runtime.js"),
1576
+ path7.join(here2, "injected-runtime.js"),
1323
1577
  // source mode (src/ dir)
1324
- path6.join(here2, "dist", "injected-runtime.js"),
1578
+ path7.join(here2, "dist", "injected-runtime.js"),
1325
1579
  // dist mode
1326
- path6.join(here2, "..", "dist", "injected-runtime.js")
1580
+ path7.join(here2, "..", "dist", "injected-runtime.js")
1327
1581
  // dist/cli.js sibling
1328
1582
  ];
1329
1583
  for (const p of candidates) {
1330
1584
  try {
1331
- cached = readFileSync6(p, "utf-8");
1585
+ cached = readFileSync7(p, "utf-8");
1332
1586
  return cached;
1333
1587
  } catch {
1334
1588
  }
@@ -1586,34 +1840,623 @@ var init_art_verify = __esm({
1586
1840
  }
1587
1841
  });
1588
1842
 
1589
- // src/browser.ts
1590
- import path7 from "path";
1591
- var browserPath, ensureBrowser;
1592
- var init_browser = __esm({
1593
- "src/browser.ts"() {
1843
+ // src/net/tunnel.ts
1844
+ import { spawn, spawnSync } from "child_process";
1845
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, chmodSync, writeFileSync as writeFileSync4 } from "fs";
1846
+ import os from "os";
1847
+ import path8 from "path";
1848
+ import { gunzipSync } from "zlib";
1849
+ var URL_PATTERN, parseTunnelUrl, TAG_PATTERN, assertSafeTag, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
1850
+ var init_tunnel = __esm({
1851
+ "src/net/tunnel.ts"() {
1594
1852
  "use strict";
1595
- ensureBrowser = async () => {
1596
- if (browserPath) {
1597
- return browserPath;
1598
- }
1599
- const { Browser, getInstalledBrowsers, install } = await import("@puppeteer/browsers");
1600
- const cacheDir = path7.resolve(process.cwd(), "node_modules/.frogoe-browser");
1601
- const installed = await getInstalledBrowsers({ cacheDir });
1602
- const existing = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
1603
- browserPath = existing?.executablePath ?? (await install({
1853
+ URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/iu;
1854
+ parseTunnelUrl = (chunk) => URL_PATTERN.exec(chunk)?.[0];
1855
+ TAG_PATTERN = /^\d{4}\.\d+\.\d+(-[A-Za-z0-9.]+)?$/u;
1856
+ assertSafeTag = (tag) => {
1857
+ if (!TAG_PATTERN.test(tag)) {
1858
+ throw new Error(
1859
+ `cloudflared version "${tag}" is not a valid release tag (expected e.g. 2025.10.1)`
1860
+ );
1861
+ }
1862
+ return tag;
1863
+ };
1864
+ octalAt = (block, offset, length) => {
1865
+ const raw = block.subarray(offset, offset + length).toString("utf-8");
1866
+ const digits = raw.replace(/[\0 ]/gu, "");
1867
+ return digits.length === 0 ? 0 : Number.parseInt(digits, 8);
1868
+ };
1869
+ extractSingleFile = (tar, wanted) => {
1870
+ for (let offset = 0; offset + 512 <= tar.length; ) {
1871
+ const header = tar.subarray(offset, offset + 512);
1872
+ const name = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/u, "");
1873
+ const size = octalAt(header, 124, 12);
1874
+ const type = header.toString("utf-8").charCodeAt(156);
1875
+ const dataStart = offset + 512;
1876
+ const dataEnd = dataStart + size;
1877
+ if (dataEnd > tar.length) return null;
1878
+ if (name === wanted && (type === 48 || type === 0)) {
1879
+ return tar.subarray(dataStart, dataEnd);
1880
+ }
1881
+ offset = dataStart + Math.ceil(size / 512) * 512;
1882
+ }
1883
+ return null;
1884
+ };
1885
+ ENV_PIN = "FROGOE_CLOUDFLARED_VERSION";
1886
+ assetName = (platform, arch) => {
1887
+ const a = arch === "arm64" ? "arm64" : arch === "x64" ? "amd64" : arch === "ia32" ? "386" : null;
1888
+ if (!a) return null;
1889
+ switch (platform) {
1890
+ case "darwin":
1891
+ return a === "386" ? null : `cloudflared-darwin-${a}.tgz`;
1892
+ case "linux":
1893
+ return `cloudflared-linux-${a}`;
1894
+ // bare binaries
1895
+ case "win32":
1896
+ return a === "arm64" ? null : `cloudflared-windows-${a}.exe`;
1897
+ // bare executables
1898
+ default:
1899
+ return null;
1900
+ }
1901
+ };
1902
+ binaryFileName = (platform) => platform === "win32" ? "cloudflared.exe" : "cloudflared";
1903
+ cacheBase = (platform, env, home) => {
1904
+ if (platform === "darwin") return path8.join(home, "Library", "Caches");
1905
+ if (platform === "win32") return env.LOCALAPPDATA ?? path8.join(home, "AppData", "Local");
1906
+ return path8.join(home, ".cache");
1907
+ };
1908
+ resolveLatestTag = async () => {
1909
+ const res = await fetch("https://api.github.com/repos/cloudflare/cloudflared/releases/latest", {
1910
+ headers: { accept: "application/vnd.github+json" }
1911
+ });
1912
+ if (!res.ok) throw new Error(`github api ${res.status}`);
1913
+ const body = await res.json();
1914
+ if (!body.tag_name) throw new Error("github api returned no tag_name");
1915
+ return body.tag_name;
1916
+ };
1917
+ mb = (bytes) => (bytes / (1024 * 1024)).toFixed(0);
1918
+ downloadWithProgress = async (res, onChunk) => {
1919
+ const total = Number(res.headers.get("content-length") ?? 0);
1920
+ const reader = res.body?.getReader();
1921
+ if (!reader) return Buffer.from(await res.arrayBuffer());
1922
+ const chunks = [];
1923
+ let done = 0;
1924
+ let nextReport = 0;
1925
+ for (; ; ) {
1926
+ const step = await reader.read();
1927
+ if (step.done) break;
1928
+ chunks.push(Buffer.from(step.value));
1929
+ done += step.value.byteLength;
1930
+ if (done >= nextReport) {
1931
+ onChunk(done, total);
1932
+ nextReport = done + 5 * 1024 * 1024;
1933
+ }
1934
+ }
1935
+ return Buffer.concat(chunks);
1936
+ };
1937
+ binaryEchoes = (bin, tag) => {
1938
+ try {
1939
+ const probe = spawnSync(bin, ["--version"], { encoding: "utf-8", timeout: 1e4 });
1940
+ const out = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
1941
+ return probe.status === 0 && out.includes(tag);
1942
+ } catch {
1943
+ return false;
1944
+ }
1945
+ };
1946
+ resolveBinary = async (onProgress) => {
1947
+ const pathProbe = spawnSync("cloudflared", ["--version"], { stdio: "ignore", timeout: 5e3 });
1948
+ if (pathProbe.status === 0) return { downloaded: false, path: "cloudflared" };
1949
+ const platform = process.platform;
1950
+ const asset = assetName(platform, process.arch);
1951
+ if (!asset) {
1952
+ throw new Error(
1953
+ `cloudflared publishes no build for ${platform}/${process.arch} \u2014 install it and put \`cloudflared\` on PATH`
1954
+ );
1955
+ }
1956
+ const tag = process.env[ENV_PIN] ? assertSafeTag(process.env[ENV_PIN]) : assertSafeTag(await resolveLatestTag());
1957
+ const root = path8.join(cacheBase(platform, process.env, os.homedir()), "frogoe", "cloudflared");
1958
+ const dir = path8.join(root, tag);
1959
+ const bin = path8.join(dir, binaryFileName(platform));
1960
+ const rootResolved = path8.resolve(root);
1961
+ const binResolved = path8.resolve(bin);
1962
+ if (!binResolved.startsWith(rootResolved + path8.sep)) {
1963
+ throw new Error("cloudflared binary path escaped the frogoe cache \u2014 refusing to execute");
1964
+ }
1965
+ if (existsSync7(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
1966
+ onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
1967
+ const res = await fetch(
1968
+ `https://github.com/cloudflare/cloudflared/releases/download/${tag}/${asset}`
1969
+ );
1970
+ if (!res.ok) throw new Error(`cloudflared ${tag} download failed (${res.status})`);
1971
+ const raw = await downloadWithProgress(res, (done, total) => {
1972
+ onProgress?.(`downloading cloudflared ${tag} \u2014 ${mb(done)}${total ? `/${mb(total)}` : ""} MB`);
1973
+ });
1974
+ const binary = asset.endsWith(".tgz") ? extractSingleFile(gunzipSync(raw), "cloudflared") : raw;
1975
+ if (!binary) throw new Error("cloudflared archive did not contain the binary");
1976
+ mkdirSync4(dir, { recursive: true });
1977
+ writeFileSync4(bin, binary);
1978
+ if (platform !== "win32") chmodSync(bin, 493);
1979
+ if (!binaryEchoes(bin, tag)) {
1980
+ throw new Error(
1981
+ `cloudflared ${tag} failed its version check \u2014 deleted; set ${ENV_PIN} or install via brew`
1982
+ );
1983
+ }
1984
+ return { downloaded: true, path: bin };
1985
+ };
1986
+ startTunnel = async (port, options) => {
1987
+ const timeoutMs = options?.timeoutMs ?? 2e4;
1988
+ const bin = await resolveBinary(options?.onProgress);
1989
+ return new Promise((resolve2, reject) => {
1990
+ const child = spawn(
1991
+ bin.path,
1992
+ ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"],
1993
+ {
1994
+ detached: true,
1995
+ stdio: ["ignore", "pipe", "pipe"],
1996
+ windowsHide: true
1997
+ }
1998
+ );
1999
+ let settled = false;
2000
+ let url;
2001
+ let buffer = "";
2002
+ const exited = new Promise((notify) => {
2003
+ child.once("exit", () => notify());
2004
+ });
2005
+ const killTree = () => {
2006
+ if (process.platform === "win32" || !child.pid) {
2007
+ child.kill("SIGTERM");
2008
+ return;
2009
+ }
2010
+ try {
2011
+ process.kill(-child.pid, "SIGTERM");
2012
+ } catch {
2013
+ child.kill("SIGTERM");
2014
+ }
2015
+ };
2016
+ const finish = (error) => {
2017
+ if (settled) return;
2018
+ settled = true;
2019
+ clearTimeout(timer);
2020
+ if (error || !url) {
2021
+ killTree();
2022
+ const tail = buffer.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).slice(-2).join(" | ");
2023
+ const base = error?.message ?? "frogoe tunnel: cloudflared exited before producing a URL";
2024
+ reject(new Error(tail ? `${base} \u2014 ${tail}` : base));
2025
+ return;
2026
+ }
2027
+ resolve2({ exited, stop: killTree, url });
2028
+ };
2029
+ const timer = setTimeout(() => {
2030
+ finish(
2031
+ new Error(
2032
+ `frogoe tunnel: no URL from cloudflared within ${timeoutMs / 1e3}s \u2014 check your internet, or brew install cloudflared`
2033
+ )
2034
+ );
2035
+ }, timeoutMs);
2036
+ child.once("exit", (code) => {
2037
+ if (!settled) {
2038
+ finish(new Error(`frogoe tunnel: cloudflared exited early (code ${code ?? "signal"})`));
2039
+ }
2040
+ });
2041
+ const watch2 = (stream) => {
2042
+ stream.on("data", (data) => {
2043
+ if (settled && url) return;
2044
+ buffer += data.toString("utf-8");
2045
+ const found = parseTunnelUrl(buffer);
2046
+ if (found && !url) {
2047
+ url = found;
2048
+ finish();
2049
+ }
2050
+ });
2051
+ };
2052
+ if (child.stderr && child.stdout) {
2053
+ watch2(child.stderr);
2054
+ watch2(child.stdout);
2055
+ }
2056
+ });
2057
+ };
2058
+ }
2059
+ });
2060
+
2061
+ // src/browser/lock.ts
2062
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, rmSync, statSync, utimesSync } from "fs";
2063
+ import path9 from "path";
2064
+ var DEFAULT_LOCK_TIMINGS, LOCK_DIR_NAME, RECLAIM_DIR_NAME, isErrno, sleep2, tryAcquireDirLock, isDirLockStale, touch, reclaimStaleLock, withInstallLock;
2065
+ var init_lock = __esm({
2066
+ "src/browser/lock.ts"() {
2067
+ "use strict";
2068
+ DEFAULT_LOCK_TIMINGS = {
2069
+ heartbeatMs: 15e3,
2070
+ pollMs: 200,
2071
+ staleMs: 12e4,
2072
+ waitNoticeMs: 1e4
2073
+ };
2074
+ LOCK_DIR_NAME = ".chrome.install.lock";
2075
+ RECLAIM_DIR_NAME = ".chrome.install.reclaim.lock";
2076
+ isErrno = (error, code) => {
2077
+ if (typeof error === "object" && error !== null && "code" in error) {
2078
+ return error.code === code;
2079
+ }
2080
+ return false;
2081
+ };
2082
+ sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
2083
+ tryAcquireDirLock = (lockDir) => {
2084
+ try {
2085
+ mkdirSync5(lockDir, { recursive: false });
2086
+ return true;
2087
+ } catch (error) {
2088
+ if (!isErrno(error, "EEXIST")) throw error;
2089
+ return false;
2090
+ }
2091
+ };
2092
+ isDirLockStale = (lockDir, staleMs) => {
2093
+ try {
2094
+ return Date.now() - statSync(lockDir).mtimeMs > staleMs;
2095
+ } catch (error) {
2096
+ if (isErrno(error, "ENOENT")) return false;
2097
+ throw error;
2098
+ }
2099
+ };
2100
+ touch = (lockDir) => {
2101
+ try {
2102
+ const now = /* @__PURE__ */ new Date();
2103
+ utimesSync(lockDir, now, now);
2104
+ } catch {
2105
+ }
2106
+ };
2107
+ reclaimStaleLock = (lockDir, reclaimDir, staleMs) => {
2108
+ if (!tryAcquireDirLock(reclaimDir)) return;
2109
+ try {
2110
+ if (isDirLockStale(lockDir, staleMs)) {
2111
+ rmSync(lockDir, { recursive: true, force: true });
2112
+ }
2113
+ } finally {
2114
+ rmSync(reclaimDir, { recursive: true, force: true });
2115
+ }
2116
+ };
2117
+ withInstallLock = async (fn, rootDir, options) => {
2118
+ const timings = options?.timings ?? DEFAULT_LOCK_TIMINGS;
2119
+ const log = options?.log ?? ((line) => console.error(line));
2120
+ const lockDir = path9.join(rootDir, LOCK_DIR_NAME);
2121
+ const reclaimDir = path9.join(rootDir, RECLAIM_DIR_NAME);
2122
+ if (!existsSync8(rootDir)) mkdirSync5(rootDir, { recursive: true });
2123
+ let deadline = Date.now() + timings.staleMs;
2124
+ const waitStart = Date.now();
2125
+ let lastNoticeMs = 0;
2126
+ for (; ; ) {
2127
+ if (existsSync8(reclaimDir)) {
2128
+ if (isDirLockStale(reclaimDir, timings.staleMs)) {
2129
+ rmSync(reclaimDir, { recursive: true, force: true });
2130
+ }
2131
+ await sleep2(timings.pollMs);
2132
+ continue;
2133
+ }
2134
+ if (tryAcquireDirLock(lockDir)) {
2135
+ rmSync(reclaimDir, { recursive: true, force: true });
2136
+ break;
2137
+ }
2138
+ const waitedMs = Date.now() - waitStart;
2139
+ if (waitedMs - lastNoticeMs >= timings.waitNoticeMs) {
2140
+ lastNoticeMs = waitedMs;
2141
+ log(
2142
+ ` browser: waiting for another frogoe process to finish installing chrome-headless-shell (${Math.round(waitedMs / 1e3)}s elapsed)\u2026`
2143
+ );
2144
+ }
2145
+ if (isDirLockStale(lockDir, timings.staleMs) || Date.now() > deadline) {
2146
+ reclaimStaleLock(lockDir, reclaimDir, timings.staleMs);
2147
+ deadline = Date.now() + timings.staleMs;
2148
+ continue;
2149
+ }
2150
+ await sleep2(timings.pollMs);
2151
+ }
2152
+ const heartbeat = setInterval(() => touch(lockDir), timings.heartbeatMs);
2153
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
2154
+ try {
2155
+ return await fn();
2156
+ } finally {
2157
+ clearInterval(heartbeat);
2158
+ rmSync(lockDir, { recursive: true, force: true });
2159
+ }
2160
+ };
2161
+ }
2162
+ });
2163
+
2164
+ // src/browser/manager.ts
2165
+ import { cpSync as cpSync2, existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync2, renameSync, rmSync as rmSync2 } from "fs";
2166
+ import { homedir } from "os";
2167
+ import path10 from "path";
2168
+ var CHROME_BUILD, BROWSER_PATH_ENV, CACHE_ROOT, CACHE_DIR, ARCHIVE_CHECKSUMS, messageOf, positiveIntEnv, isCorruptInstallError, browserPathHintForPlatform, wrapDownloadFailure, isTimeoutError, wrapLaunchFailure, findInCache, legacyCacheDir, migrateLegacyCache, pruneLegacyCache, progressReporter, downloadBrowser, resolved, ensureBrowser;
2169
+ var init_manager = __esm({
2170
+ "src/browser/manager.ts"() {
2171
+ "use strict";
2172
+ init_tunnel();
2173
+ init_lock();
2174
+ CHROME_BUILD = "131.0.6778.204";
2175
+ BROWSER_PATH_ENV = "FROGOE_BROWSER_PATH";
2176
+ CACHE_ROOT = path10.join(cacheBase(process.platform, process.env, homedir()), "frogoe");
2177
+ CACHE_DIR = path10.join(CACHE_ROOT, "chrome");
2178
+ ARCHIVE_CHECKSUMS = {
2179
+ linux: "afaac86e302c4874245991a3d509d529c50ecdd447affff0cdc2b08520906c32",
2180
+ mac: "933463c27a951d3fc153c408c4b8a96c89b9344379963f68a5ff9aa1718bcaf9",
2181
+ mac_arm: "9dae11ffda8d77f92c56f64de84f81fa6781c9d41235a8e9397117d7d004a017",
2182
+ win32: "e1a14441accf82785fd81b78d34d8faf5cc78bbab6b2f90fa7e7e972d93ed77f",
2183
+ win64: "d45834686f461ef6f487c0eacc2063bc311a5de86e92de46600895bf44136714"
2184
+ };
2185
+ messageOf = (error) => {
2186
+ if (error instanceof Error) return error.message;
2187
+ if (typeof error === "string") return error;
2188
+ return "";
2189
+ };
2190
+ positiveIntEnv = (name, raw, fallback) => {
2191
+ if (raw === void 0 || raw.trim() === "") return fallback;
2192
+ const trimmed = raw.trim();
2193
+ if (!/^\d+$/u.test(trimmed) || Number.parseInt(trimmed, 10) <= 0) {
2194
+ throw new Error(
2195
+ `frogoe browser: ${name} must be a positive integer of milliseconds (got "${raw}") \u2014 unset it or fix the value, then re-run`
2196
+ );
2197
+ }
2198
+ return Number.parseInt(trimmed, 10);
2199
+ };
2200
+ isCorruptInstallError = (error) => {
2201
+ const message2 = messageOf(error).toLowerCase();
2202
+ return message2.includes("end of central directory") || message2.includes("end-of-central-directory") || message2.includes("invalid or corrupt") || message2.includes("corrupt zip") || message2.includes("corrupted") || message2.includes("not a zip") || message2.includes("unexpected end of") || message2.includes("integrity check failed") || message2.includes("exists but the executable");
2203
+ };
2204
+ browserPathHintForPlatform = (platform) => {
2205
+ if (platform === "darwin") return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
2206
+ if (platform === "win32") return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
2207
+ return "/usr/bin/google-chrome";
2208
+ };
2209
+ wrapDownloadFailure = (cause) => {
2210
+ const original = messageOf(cause) || String(cause);
2211
+ return new Error(
2212
+ `frogoe browser: failed to download chrome-headless-shell ${CHROME_BUILD} \u2014 ${original}
2213
+
2214
+ Point frogoe at an already-installed Chrome/Chromium instead:
2215
+
2216
+ export ${BROWSER_PATH_ENV}="${browserPathHintForPlatform(process.platform)}"
2217
+
2218
+ Then re-run your command. Any Chrome build works for the sandbox; the pinned
2219
+ headless-shell re-downloads into ${CACHE_DIR} once the network allows.`,
2220
+ { cause: cause instanceof Error ? cause : void 0 }
2221
+ );
2222
+ };
2223
+ isTimeoutError = (error) => {
2224
+ if (!(error instanceof Error)) return false;
2225
+ return error.name === "TimeoutError" || /timed out \d+ ms/u.test(error.message);
2226
+ };
2227
+ wrapLaunchFailure = (cause, timeoutMs) => {
2228
+ const head = isTimeoutError(cause) ? `frogoe browser: headless chrome did not start within ${String(timeoutMs)} ms.
2229
+
2230
+ Raise the budget or point frogoe at an installed Chrome:
2231
+
2232
+ FROGOE_LAUNCH_TIMEOUT_MS=${String(timeoutMs * 2)} <your command>
2233
+ export ${BROWSER_PATH_ENV}="${browserPathHintForPlatform(process.platform)}"
2234
+ ` : `frogoe browser: headless chrome failed to launch \u2014 ${messageOf(cause) || String(cause)}
2235
+
2236
+ If a local Chrome exists, point frogoe at it:
2237
+
2238
+ export ${BROWSER_PATH_ENV}="${browserPathHintForPlatform(process.platform)}"
2239
+ `;
2240
+ return new Error(
2241
+ `${head}
2242
+ Then re-run your command. (The pinned binary lives in ${CACHE_DIR} \u2014
2243
+ see skills/frogoe-cli \u2192 references/live-sandbox.md)`,
2244
+ { cause: cause instanceof Error ? cause : void 0 }
2245
+ );
2246
+ };
2247
+ findInCache = async (cacheDir, buildId) => {
2248
+ if (!existsSync9(cacheDir)) return {};
2249
+ const { Browser, detectBrowserPlatform, getInstalledBrowsers } = await import("@puppeteer/browsers");
2250
+ const platform = detectBrowserPlatform();
2251
+ const match = (await getInstalledBrowsers({ cacheDir })).find(
2252
+ (entry) => entry.browser === Browser.CHROMEHEADLESSSHELL && entry.buildId === buildId && entry.platform === platform
2253
+ );
2254
+ if (!match) return {};
2255
+ if (existsSync9(match.executablePath)) return { executablePath: match.executablePath };
2256
+ return { staleInstallPath: match.path };
2257
+ };
2258
+ legacyCacheDir = (dir) => path10.resolve(dir, "node_modules", ".frogoe-browser");
2259
+ migrateLegacyCache = async (legacyDir, cacheDir, buildId, log) => {
2260
+ if (!existsSync9(legacyDir)) return null;
2261
+ const { Browser, getInstalledBrowsers } = await import("@puppeteer/browsers");
2262
+ const legacy = (await getInstalledBrowsers({ cacheDir: legacyDir })).find(
2263
+ (entry) => entry.browser === Browser.CHROMEHEADLESSSHELL && entry.buildId === buildId && existsSync9(entry.executablePath)
2264
+ );
2265
+ if (!legacy) return null;
2266
+ const browserRootName = path10.basename(path10.dirname(legacy.path));
2267
+ const installName = path10.basename(legacy.path);
2268
+ const destination = path10.join(cacheDir, browserRootName, installName);
2269
+ mkdirSync6(path10.dirname(destination), { recursive: true });
2270
+ rmSync2(destination, { recursive: true, force: true });
2271
+ try {
2272
+ try {
2273
+ renameSync(legacy.path, destination);
2274
+ } catch (error) {
2275
+ if (!isErrno(error, "EXDEV")) throw error;
2276
+ const staging = `${destination}.migrating-${String(process.pid)}`;
2277
+ rmSync2(staging, { recursive: true, force: true });
2278
+ try {
2279
+ cpSync2(legacy.path, staging, { recursive: true });
2280
+ renameSync(staging, destination);
2281
+ } finally {
2282
+ rmSync2(staging, { recursive: true, force: true });
2283
+ }
2284
+ rmSync2(legacy.path, { recursive: true, force: true });
2285
+ }
2286
+ } catch {
2287
+ return null;
2288
+ }
2289
+ const found = await findInCache(cacheDir, buildId);
2290
+ if (!found.executablePath) return null;
2291
+ rmSync2(path10.dirname(legacy.path), { recursive: true, force: true });
2292
+ rmSync2(legacyDir, { recursive: true, force: true });
2293
+ log?.(` browser: migrated the cached chrome-headless-shell into ${cacheDir} (one-time)`);
2294
+ return found.executablePath;
2295
+ };
2296
+ pruneLegacyCache = (legacyDir, buildId) => {
2297
+ if (!existsSync9(legacyDir)) return false;
2298
+ try {
2299
+ const browserRoot = path10.join(legacyDir, "chrome-headless-shell");
2300
+ if (existsSync9(browserRoot)) {
2301
+ const entries = readdirSync2(browserRoot);
2302
+ if (entries.length > 0 && !entries.some((name) => name.endsWith(`-${buildId}`))) {
2303
+ return false;
2304
+ }
2305
+ } else if (readdirSync2(legacyDir).length > 0) {
2306
+ return false;
2307
+ }
2308
+ rmSync2(legacyDir, { recursive: true, force: true });
2309
+ return true;
2310
+ } catch {
2311
+ return false;
2312
+ }
2313
+ };
2314
+ progressReporter = () => {
2315
+ const tty = process.stderr.isTTY === true;
2316
+ const mb2 = (bytes) => (bytes / (1024 * 1024)).toFixed(1);
2317
+ let lastPaintMs = 0;
2318
+ let nextMilestone = 0.25;
2319
+ return (downloaded, total) => {
2320
+ if (tty) {
2321
+ const finished = total > 0 && downloaded >= total;
2322
+ if (Date.now() - lastPaintMs < 100 && !finished) return;
2323
+ lastPaintMs = Date.now();
2324
+ process.stderr.write(
2325
+ `\r browser: downloading chrome-headless-shell \u2014 ${mb2(downloaded)}${total > 0 ? `/${mb2(total)}` : ""} MB`
2326
+ );
2327
+ if (finished) process.stderr.write("\n");
2328
+ return;
2329
+ }
2330
+ if (total <= 0 || downloaded / total < nextMilestone) return;
2331
+ process.stderr.write(
2332
+ ` browser: downloading chrome-headless-shell \u2014 ${Math.round(nextMilestone * 100)}% (${mb2(total)} MB)
2333
+ `
2334
+ );
2335
+ nextMilestone += 0.25;
2336
+ };
2337
+ };
2338
+ downloadBrowser = async (log) => {
2339
+ const { Browser, detectBrowserPlatform, install } = await import("@puppeteer/browsers");
2340
+ const platform = detectBrowserPlatform();
2341
+ if (!platform) {
2342
+ throw wrapDownloadFailure(
2343
+ new Error(`no chrome-headless-shell build exists for ${process.platform}/${process.arch}`)
2344
+ );
2345
+ }
2346
+ const expectedHash = ARCHIVE_CHECKSUMS[platform];
2347
+ if (expectedHash === void 0) {
2348
+ log(
2349
+ ` browser: no pinned checksum for ${platform} \u2014 downloading WITHOUT integrity verification`
2350
+ );
2351
+ }
2352
+ const runInstall = () => install({
1604
2353
  browser: Browser.CHROMEHEADLESSSHELL,
1605
- buildId: "131.0.6778.204",
1606
- cacheDir,
1607
- unpack: true
1608
- })).executablePath;
1609
- return browserPath;
2354
+ buildId: CHROME_BUILD,
2355
+ cacheDir: CACHE_DIR,
2356
+ unpack: true,
2357
+ ...expectedHash === void 0 ? {} : { expectedHash },
2358
+ downloadProgressCallback: progressReporter()
2359
+ });
2360
+ try {
2361
+ return (await runInstall()).executablePath;
2362
+ } catch (error) {
2363
+ if (!isCorruptInstallError(error)) throw wrapDownloadFailure(error);
2364
+ log(" browser: cached archive was corrupt \u2014 purging and retrying once\u2026");
2365
+ rmSync2(CACHE_DIR, { recursive: true, force: true });
2366
+ try {
2367
+ return (await runInstall()).executablePath;
2368
+ } catch (retryError) {
2369
+ throw wrapDownloadFailure(retryError);
2370
+ }
2371
+ }
2372
+ };
2373
+ ensureBrowser = async (options) => {
2374
+ if (resolved) return resolved;
2375
+ const log = (line) => console.error(line);
2376
+ const fromEnv = process.env[BROWSER_PATH_ENV]?.trim();
2377
+ if (fromEnv !== void 0 && fromEnv !== "") {
2378
+ if (!existsSync9(fromEnv)) {
2379
+ throw new Error(
2380
+ `frogoe browser: ${BROWSER_PATH_ENV} is set but "${fromEnv}" does not exist \u2014 fix the path or unset the variable, then re-run`
2381
+ );
2382
+ }
2383
+ resolved = fromEnv;
2384
+ return resolved;
2385
+ }
2386
+ const legacyDirs = options?.legacyDirs ?? [legacyCacheDir(process.cwd())];
2387
+ const pre = await findInCache(CACHE_DIR, CHROME_BUILD);
2388
+ if (pre.executablePath) {
2389
+ resolved = pre.executablePath;
2390
+ } else {
2391
+ resolved = await withInstallLock(
2392
+ async () => {
2393
+ const post = await findInCache(CACHE_DIR, CHROME_BUILD);
2394
+ if (post.executablePath) return post.executablePath;
2395
+ if (post.staleInstallPath) {
2396
+ log(" browser: incomplete install in the cache \u2014 purging before reinstalling\u2026");
2397
+ rmSync2(post.staleInstallPath, { recursive: true, force: true });
2398
+ }
2399
+ for (const legacyDir of legacyDirs) {
2400
+ const migrated = await migrateLegacyCache(legacyDir, CACHE_DIR, CHROME_BUILD, log);
2401
+ if (migrated) return migrated;
2402
+ }
2403
+ return downloadBrowser(log);
2404
+ },
2405
+ CACHE_ROOT,
2406
+ { log }
2407
+ );
2408
+ }
2409
+ for (const legacyDir of legacyDirs) {
2410
+ if (pruneLegacyCache(legacyDir, CHROME_BUILD)) {
2411
+ log(` browser: removed the superseded per-project cache at ${legacyDir}`);
2412
+ }
2413
+ }
2414
+ return resolved;
2415
+ };
2416
+ }
2417
+ });
2418
+
2419
+ // src/browser/launch.ts
2420
+ var LAUNCH_TIMEOUT_ENV, PROTOCOL_TIMEOUT_ENV, DEFAULT_LAUNCH_TIMEOUT_MS, DEFAULT_PROTOCOL_TIMEOUT_MS, launchBudgets, launchBrowser;
2421
+ var init_launch = __esm({
2422
+ "src/browser/launch.ts"() {
2423
+ "use strict";
2424
+ init_manager();
2425
+ LAUNCH_TIMEOUT_ENV = "FROGOE_LAUNCH_TIMEOUT_MS";
2426
+ PROTOCOL_TIMEOUT_ENV = "FROGOE_PROTOCOL_TIMEOUT_MS";
2427
+ DEFAULT_LAUNCH_TIMEOUT_MS = 12e4;
2428
+ DEFAULT_PROTOCOL_TIMEOUT_MS = 3e5;
2429
+ launchBudgets = (env = process.env) => ({
2430
+ launchMs: positiveIntEnv(LAUNCH_TIMEOUT_ENV, env[LAUNCH_TIMEOUT_ENV], DEFAULT_LAUNCH_TIMEOUT_MS),
2431
+ protocolMs: positiveIntEnv(
2432
+ PROTOCOL_TIMEOUT_ENV,
2433
+ env[PROTOCOL_TIMEOUT_ENV],
2434
+ DEFAULT_PROTOCOL_TIMEOUT_MS
2435
+ )
2436
+ });
2437
+ launchBrowser = async (options) => {
2438
+ const { default: puppeteer } = await import("puppeteer-core");
2439
+ const budgets = launchBudgets();
2440
+ const executablePath = await ensureBrowser({ legacyDirs: options?.legacyDirs });
2441
+ try {
2442
+ return await puppeteer.launch({
2443
+ args: ["--no-sandbox", "--disable-gpu"],
2444
+ defaultViewport: options?.defaultViewport === void 0 ? null : options.defaultViewport,
2445
+ executablePath,
2446
+ headless: true,
2447
+ protocolTimeout: budgets.protocolMs,
2448
+ timeout: budgets.launchMs
2449
+ });
2450
+ } catch (error) {
2451
+ throw wrapLaunchFailure(error, budgets.launchMs);
2452
+ }
1610
2453
  };
1611
2454
  }
1612
2455
  });
1613
2456
 
1614
2457
  // src/net/ip.ts
1615
2458
  import { execSync } from "child_process";
1616
- import os from "os";
2459
+ import os2 from "os";
1617
2460
  var VIRTUAL, isPrivateV4, isLoopback, selfAddresses, pickLanIp, interfaceFromRouteOutput, ROUTE_PROBES, routedInterfaceName, resolveLan;
1618
2461
  var init_ip = __esm({
1619
2462
  "src/net/ip.ts"() {
@@ -1713,7 +2556,7 @@ var init_ip = __esm({
1713
2556
  }
1714
2557
  return null;
1715
2558
  };
1716
- resolveLan = () => pickLanIp(os.networkInterfaces(), routedInterfaceName());
2559
+ resolveLan = () => pickLanIp(os2.networkInterfaces(), routedInterfaceName());
1717
2560
  }
1718
2561
  });
1719
2562
 
@@ -1832,8 +2675,8 @@ var init_records = __esm({
1832
2675
  });
1833
2676
 
1834
2677
  // src/telemetry/session.ts
1835
- import { appendFileSync, mkdirSync as mkdirSync3, readdirSync as readdirSync2 } from "fs";
1836
- import path8 from "path";
2678
+ import { appendFileSync, mkdirSync as mkdirSync7, readdirSync as readdirSync3 } from "fs";
2679
+ import path11 from "path";
1837
2680
  var pad, sessionStamp, createSessionStore, latestSessionFile;
1838
2681
  var init_session = __esm({
1839
2682
  "src/telemetry/session.ts"() {
@@ -1844,25 +2687,25 @@ var init_session = __esm({
1844
2687
  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
1845
2688
  };
1846
2689
  createSessionStore = (gameDir, startedWall) => {
1847
- const dir = path8.join(gameDir, ".frogoe", "sessions");
2690
+ const dir = path11.join(gameDir, ".frogoe", "sessions");
1848
2691
  let file;
1849
2692
  return {
1850
2693
  file: () => file,
1851
2694
  write: (records) => {
1852
2695
  if (records.length === 0) return;
1853
2696
  if (!file) {
1854
- mkdirSync3(dir, { recursive: true });
1855
- file = path8.join(dir, `${sessionStamp(startedWall)}.jsonl`);
2697
+ mkdirSync7(dir, { recursive: true });
2698
+ file = path11.join(dir, `${sessionStamp(startedWall)}.jsonl`);
1856
2699
  }
1857
2700
  appendFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
1858
2701
  }
1859
2702
  };
1860
2703
  };
1861
2704
  latestSessionFile = (gameDir) => {
1862
- const dir = path8.join(gameDir, ".frogoe", "sessions");
2705
+ const dir = path11.join(gameDir, ".frogoe", "sessions");
1863
2706
  try {
1864
- const files = readdirSync2(dir).filter((f) => f.endsWith(".jsonl")).sort();
1865
- return files.length > 0 ? path8.join(dir, files[files.length - 1] ?? "") : null;
2707
+ const files = readdirSync3(dir).filter((f) => f.endsWith(".jsonl")).sort();
2708
+ return files.length > 0 ? path11.join(dir, files[files.length - 1] ?? "") : null;
1866
2709
  } catch {
1867
2710
  return null;
1868
2711
  }
@@ -1875,9 +2718,9 @@ var run_exports = {};
1875
2718
  __export(run_exports, {
1876
2719
  startServer: () => startServer
1877
2720
  });
1878
- import { existsSync as existsSync6, readFileSync as readFileSync7, statSync, watch } from "fs";
1879
- import os2 from "os";
1880
- import path9 from "path";
2721
+ import { existsSync as existsSync10, readFileSync as readFileSync8, statSync as statSync2, watch } from "fs";
2722
+ import os3 from "os";
2723
+ import path12 from "path";
1881
2724
  import { createAdaptorServer } from "@hono/node-server";
1882
2725
  import { getConnInfo } from "@hono/node-server/conninfo";
1883
2726
  import { Hono } from "hono";
@@ -1886,6 +2729,7 @@ var init_run = __esm({
1886
2729
  "src/run.ts"() {
1887
2730
  "use strict";
1888
2731
  init_ip();
2732
+ init_font_proxy();
1889
2733
  init_records();
1890
2734
  init_session();
1891
2735
  buildDevScript = (version) => `<script>(function(){var v="${String(version)}";try{var es=new EventSource("/__frogoe/reload");es.onmessage=function(){location.reload()};es.onerror=function(){es.close()};}catch(e){}setInterval(function(){fetch("/__frogoe/version",{cache:"no-store"}).then(function(r){return r.text()}).then(function(t){if(t!==v)location.reload()}).catch(function(){})},2000);var fps=[],cnt=0,sec=performance.now(),evs=[],up0=performance.now();function up(){return (performance.now()-up0)/1000}function tick(){cnt++;var n=performance.now();if(n-sec>=1000){fps.push(cnt);cnt=0;sec=n}requestAnimationFrame(tick)}requestAnimationFrame(tick);addEventListener("error",function(e){evs.push({type:"error",msg:String(e.message||e).slice(0,200),up:up()})});addEventListener("unhandledrejection",function(e){evs.push({type:"rejection",msg:String(e.reason).slice(0,200),up:up()})});document.addEventListener("visibilitychange",function(){evs.push({type:document.hidden?"hidden":"visible",up:up()})});function flush(beacon){var p={v:1,up:up(),fps:fps.splice(0),events:evs.splice(0)};if(performance.memory)p.mem=Math.round(performance.memory.usedJSHeapSize/1048576);var b=JSON.stringify(p);if(beacon&&navigator.sendBeacon){navigator.sendBeacon("/__frogoe/metrics",new Blob([b],{type:"application/json"}));return}fetch("/__frogoe/metrics",{method:"POST",body:b,headers:{"content-type":"application/json"},keepalive:true}).catch(function(){});}document.addEventListener("visibilitychange",function(){if(document.hidden)flush(true)});addEventListener("pagehide",function(){flush(true)});setInterval(function(){flush(false)},5000);})();</script>`;
@@ -1905,15 +2749,15 @@ var init_run = __esm({
1905
2749
  woff2: "font/woff2"
1906
2750
  };
1907
2751
  startServer = async (dir, requestedPort = 0, telemetry) => {
1908
- const root = path9.resolve(dir);
1909
- if (!existsSync6(path9.join(root, "index.html"))) {
2752
+ const root = path12.resolve(dir);
2753
+ if (!existsSync10(path12.join(root, "index.html"))) {
1910
2754
  throw new Error(`frogoe run: no index.html in ${root} \u2014 is this a game folder?`);
1911
2755
  }
1912
2756
  const clients = /* @__PURE__ */ new Set();
1913
2757
  let version = 0;
1914
2758
  let remoteSeen = false;
1915
2759
  let lastRemote = "";
1916
- const own = selfAddresses(os2.networkInterfaces());
2760
+ const own = selfAddresses(os3.networkInterfaces());
1917
2761
  const session = telemetry ? createSessionStore(root, Date.now()) : void 0;
1918
2762
  const app = new Hono();
1919
2763
  app.use("*", async (c, next) => {
@@ -1948,6 +2792,22 @@ var init_run = __esm({
1948
2792
  "/__frogoe/version",
1949
2793
  (c) => c.text(String(version), 200, { "cache-control": "no-store" })
1950
2794
  );
2795
+ const fontCacheDir = path12.join(root, ".frogoe", "font-cache");
2796
+ app.get("/__frogoe/font/:token", async (c) => {
2797
+ const url = decodeProxyToken(c.req.param("token"));
2798
+ if (url === null) {
2799
+ return c.text("forbidden", 403);
2800
+ }
2801
+ const result = await proxyFontRequest(url, fontCacheDir);
2802
+ return new Response(result.body, {
2803
+ headers: {
2804
+ "cache-control": "no-store",
2805
+ "content-type": result.contentType,
2806
+ "x-frogoe-cache": result.fromCache ? "hit" : "miss"
2807
+ },
2808
+ status: result.status
2809
+ });
2810
+ });
1951
2811
  app.post("/__frogoe/metrics", async (c) => {
1952
2812
  try {
1953
2813
  const payload = JSON.parse(await c.req.text());
@@ -1964,23 +2824,24 @@ var init_run = __esm({
1964
2824
  });
1965
2825
  app.get("*", (c) => {
1966
2826
  const raw = decodeURIComponent(new URL(c.req.url).pathname);
1967
- const safe = path9.normalize(raw).replaceAll("\\", "/");
1968
- let file = path9.join(root, safe === "/" ? "index.html" : safe);
2827
+ const safe = path12.normalize(raw).replaceAll("\\", "/");
2828
+ let file = path12.join(root, safe === "/" ? "index.html" : safe);
1969
2829
  if (!file.startsWith(root)) {
1970
2830
  return c.text("forbidden", 403);
1971
2831
  }
1972
- if (existsSync6(file) && statSync(file).isDirectory()) {
1973
- file = path9.join(file, "index.html");
2832
+ if (existsSync10(file) && statSync2(file).isDirectory()) {
2833
+ file = path12.join(file, "index.html");
1974
2834
  }
1975
- if (!existsSync6(file)) {
2835
+ if (!existsSync10(file)) {
1976
2836
  return c.text(`frogoe run: not found: ${raw}`, 404);
1977
2837
  }
1978
- const body = readFileSync7(file);
1979
- const ext = path9.extname(file).slice(1).toLowerCase();
2838
+ const body = readFileSync8(file);
2839
+ const ext = path12.extname(file).slice(1).toLowerCase();
1980
2840
  const type = MIME2[ext] ?? "application/octet-stream";
1981
2841
  if (ext === "html" || ext === "htm") {
1982
2842
  const html = body.toString("utf-8");
1983
- const injected = /<\/body>/iu.test(html) ? html.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : html + buildDevScript(version);
2843
+ const proxied = rewriteFontLinks(html);
2844
+ const injected = /<\/body>/iu.test(proxied) ? proxied.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : proxied + buildDevScript(version);
1984
2845
  return c.body(injected, 200, {
1985
2846
  "cache-control": "no-store",
1986
2847
  "content-type": type
@@ -2005,7 +2866,7 @@ var init_run = __esm({
2005
2866
  const lan = lanInfo.ip ? `http://${lanInfo.ip}:${port}` : void 0;
2006
2867
  let timer;
2007
2868
  const watcher = watch(root, { recursive: true }, (_event, file) => {
2008
- const first = file?.split(path9.sep)[0];
2869
+ const first = file?.split(path12.sep)[0];
2009
2870
  if (first === "snapshots" || first === ".frogoe" || first === "dist") {
2010
2871
  return;
2011
2872
  }
@@ -2039,9 +2900,9 @@ var init_run = __esm({
2039
2900
  });
2040
2901
 
2041
2902
  // src/raster.ts
2042
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
2043
- import path10 from "path";
2044
- var SCENES, sleep2, rasterScriptFor, rasterizeArt;
2903
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync9, statSync as statSync3 } from "fs";
2904
+ import path13 from "path";
2905
+ var SCENES, sleep3, rasterScriptFor, rasterizeArt;
2045
2906
  var init_raster = __esm({
2046
2907
  "src/raster.ts"() {
2047
2908
  "use strict";
@@ -2049,32 +2910,33 @@ var init_raster = __esm({
2049
2910
  init_runtime_source();
2050
2911
  init_art_verify();
2051
2912
  init_runtime_source();
2052
- init_browser();
2913
+ init_launch();
2914
+ init_manager();
2053
2915
  SCENES = [
2054
2916
  {
2055
2917
  draw: "drawPoster",
2056
2918
  height: 1920,
2057
- out: path10.join("dist", "assets", "poster.png"),
2919
+ out: path13.join("dist", "assets", "poster.png"),
2058
2920
  size: "w, h",
2059
- source: path10.join("assets", "poster.js"),
2921
+ source: path13.join("assets", "poster.js"),
2060
2922
  width: 1080
2061
2923
  },
2062
2924
  {
2063
2925
  draw: "drawIcon",
2064
2926
  height: 1024,
2065
- out: path10.join("dist", "assets", "icon.png"),
2927
+ out: path13.join("dist", "assets", "icon.png"),
2066
2928
  size: "size",
2067
- source: path10.join("assets", "icon.js"),
2929
+ source: path13.join("assets", "icon.js"),
2068
2930
  width: 1024
2069
2931
  }
2070
2932
  ];
2071
- sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
2933
+ sleep3 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
2072
2934
  rasterScriptFor = (scene, palette) => {
2073
2935
  const sizeArg = scene.draw === "drawIcon" ? String(scene.width) : `${String(scene.width)}, ${String(scene.height)}`;
2074
2936
  const analyze = scene.draw === "drawIcon" ? "analyzeIconCorners" : "analyzeTitleBand";
2075
2937
  return `(async () => {
2076
2938
  ${runtimeSource()}
2077
- const mod = await import("./assets/${path10.basename(scene.source)}");
2939
+ const mod = await import("./assets/${path13.basename(scene.source)}");
2078
2940
  const canvas = document.createElement("canvas");
2079
2941
  canvas.id = "__frogoe_raster";
2080
2942
  canvas.width = ${String(scene.width)};
@@ -2101,8 +2963,8 @@ var init_raster = __esm({
2101
2963
  })()`;
2102
2964
  };
2103
2965
  rasterizeArt = async (options) => {
2104
- const dir = path10.resolve(options.dir);
2105
- const missing = SCENES.filter((scene) => !existsSync7(path10.join(dir, scene.source)));
2966
+ const dir = path13.resolve(options.dir);
2967
+ const missing = SCENES.filter((scene) => !existsSync11(path13.join(dir, scene.source)));
2106
2968
  if (missing.length > 0) {
2107
2969
  throw new Error(
2108
2970
  `bundle/art-missing \u2014 author the identity scenes first (${missing.map((scene) => scene.source).join(", ")}); run \`frogoe check\` and see frogoe-creative \u2192 references/art.md`
@@ -2110,17 +2972,10 @@ var init_raster = __esm({
2110
2972
  }
2111
2973
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
2112
2974
  const server = await startServer2(dir);
2113
- const { default: puppeteer } = await import("puppeteer-core");
2114
- const executablePath = await ensureBrowser();
2115
- const browser = await puppeteer.launch({
2116
- args: ["--no-sandbox", "--disable-gpu"],
2117
- defaultViewport: null,
2118
- executablePath,
2119
- headless: true
2120
- });
2975
+ const browser = await launchBrowser({ legacyDirs: [legacyCacheDir(dir)] });
2121
2976
  try {
2122
2977
  const base = server.urls.local.replace(/\/$/u, "");
2123
- const brief = parseBrief(readFileSync8(path10.join(dir, "BRIEF.md"), "utf-8"));
2978
+ const brief = parseBrief(readFileSync9(path13.join(dir, "BRIEF.md"), "utf-8"));
2124
2979
  const palette = {
2125
2980
  accent: brief?.accent ?? "#ff3b3b",
2126
2981
  bg: brief?.bg ?? "#101418",
@@ -2166,7 +3021,7 @@ var init_raster = __esm({
2166
3021
  }
2167
3022
  } else {
2168
3023
  const report = await page.evaluate("window.__frogoeArtReport");
2169
- const gameSource = readFileSync8(path10.join(dir, "game.js"), "utf-8");
3024
+ const gameSource = readFileSync9(path13.join(dir, "game.js"), "utf-8");
2170
3025
  const verdict = verifyIconFullbleed(report, [...hexColorsOf(gameSource)]);
2171
3026
  if (verdict !== null) throw new Error(verdict);
2172
3027
  if (metrics.coverage < 0.08) {
@@ -2175,13 +3030,13 @@ var init_raster = __esm({
2175
3030
  );
2176
3031
  }
2177
3032
  }
2178
- await sleep2(80);
3033
+ await sleep3(80);
2179
3034
  const canvas = await page.$("#__frogoe_raster");
2180
3035
  if (canvas === null) throw new Error(`bundle/art-crash \u2014 ${scene.source}: no canvas`);
2181
- const outPath = path10.join(dir, scene.out);
2182
- mkdirSync4(path10.dirname(outPath), { recursive: true });
3036
+ const outPath = path13.join(dir, scene.out);
3037
+ mkdirSync8(path13.dirname(outPath), { recursive: true });
2183
3038
  await canvas.screenshot({ omitBackground: true, path: outPath, type: "png" });
2184
- files.push({ bytes: statSync2(outPath).size, file: scene.out });
3039
+ files.push({ bytes: statSync3(outPath).size, file: scene.out });
2185
3040
  } finally {
2186
3041
  await page.close();
2187
3042
  }
@@ -2201,8 +3056,8 @@ __export(bundle_exports, {
2201
3056
  command: () => command2
2202
3057
  });
2203
3058
  import { defineCommand as defineCommand2 } from "citty";
2204
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
2205
- import path11 from "path";
3059
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync5 } from "fs";
3060
+ import path14 from "path";
2206
3061
  var command2;
2207
3062
  var init_bundle2 = __esm({
2208
3063
  "src/commands/bundle.ts"() {
@@ -2218,9 +3073,9 @@ var init_bundle2 = __esm({
2218
3073
  async run({ args }) {
2219
3074
  const dir = args.dir ? String(args.dir) : process.cwd();
2220
3075
  const report = await bundle({ dir });
2221
- const outPath = args.out ? path11.resolve(String(args.out)) : path11.join(dir, "dist", "index.html");
2222
- mkdirSync5(path11.dirname(outPath), { recursive: true });
2223
- writeFileSync3(outPath, report.artifact, "utf-8");
3076
+ const outPath = args.out ? path14.resolve(String(args.out)) : path14.join(dir, "dist", "index.html");
3077
+ mkdirSync9(path14.dirname(outPath), { recursive: true });
3078
+ writeFileSync5(outPath, report.artifact, "utf-8");
2224
3079
  const art = await rasterizeArt({ dir });
2225
3080
  for (const warning of [...report.warnings, ...art.warnings]) {
2226
3081
  console.log(` \u26A0 ${warning}`);
@@ -2479,6 +3334,9 @@ var init_driver = __esm({
2479
3334
  });
2480
3335
  await page.mouse.up();
2481
3336
  },
3337
+ async type(text) {
3338
+ await page.keyboard.type(text, { delay: 30 });
3339
+ },
2482
3340
  async clickRetryAwaitReload(timeoutMs) {
2483
3341
  const interactable = `(() => {
2484
3342
  const b = document.querySelector("[data-block-retry]");
@@ -2535,7 +3393,7 @@ var init_types = __esm({
2535
3393
  });
2536
3394
 
2537
3395
  // src/live/decisions.ts
2538
- var FPS_FLOOR2, FPS_SUSTAINED_WINDOW, FROZEN_STREAK, outlineFinding, collapseFinding, pageErrorFinding, consoleErrorFinding, canvasMissingFinding, canvasUnpaintedFinding, contractMissingFinding, stateStuckFinding, earlyDeathFinding, fpsFinding, fpsSustainedFinding, frozenFrameFinding, pausedFinding, stateCorruptFinding, playabilityFinding, finishEventFinding, neverEndsFinding, audioLockedFinding, noGameoverCardFinding, THROTTLE_RATE, THROTTLED_FPS_FLOOR, fpsThrottledFinding, noRetryFinding, retryDeadFinding, rebootFinding;
3396
+ var FPS_FLOOR2, FPS_SUSTAINED_WINDOW, FROZEN_STREAK, END_BUDGET_MS, ROUND_END_GRACE_MS, endBudgetMs, warnsWhenItNeverEnds, outlineFinding, collapseFinding, pageErrorFinding, consoleErrorFinding, canvasMissingFinding, canvasUnpaintedFinding, contractMissingFinding, stateStuckFinding, earlyDeathFinding, fpsFinding, fpsSustainedFinding, frozenFrameFinding, pausedFinding, stateCorruptFinding, playabilityFinding, finishEventFinding, neverEndsFinding, audioLockedFinding, noGameoverCardFinding, THROTTLE_RATE, THROTTLED_FPS_FLOOR, fpsThrottledFinding, noRetryFinding, retryDeadFinding, rebootFinding;
2539
3397
  var init_decisions = __esm({
2540
3398
  "src/live/decisions.ts"() {
2541
3399
  "use strict";
@@ -2543,6 +3401,10 @@ var init_decisions = __esm({
2543
3401
  FPS_FLOOR2 = 30;
2544
3402
  FPS_SUSTAINED_WINDOW = 3;
2545
3403
  FROZEN_STREAK = 3;
3404
+ END_BUDGET_MS = 45e3;
3405
+ ROUND_END_GRACE_MS = 8e3;
3406
+ endBudgetMs = (session) => session === "round" ? ROUND_END_GRACE_MS : session === "toy" ? 0 : END_BUDGET_MS;
3407
+ warnsWhenItNeverEnds = (session) => session === "blitz";
2546
3408
  outlineFinding = (measures) => {
2547
3409
  const bare = measures.filter((m) => !m.hasOutline);
2548
3410
  if (bare.length === 0 || !bare[0]) {
@@ -2758,7 +3620,7 @@ var init_decisions = __esm({
2758
3620
  neverEndsFinding = (budgetMs) => finding({
2759
3621
  code: "live/never-ends",
2760
3622
  file: "game.js",
2761
- fix: `no death within ${Math.round(budgetMs / 1e3)}s of passive play \u2014 fine for endless/sandbox games, but feed games are short replayable loops; most deaths should arrive in seconds`,
3623
+ fix: `no death within ${Math.round(budgetMs / 1e3)}s of passive play \u2014 blitz feed games are short loops; declare session: round in BRIEF.md for turn-based games or session: toy for endless toys, and the gate stops waiting`,
2762
3624
  message: "game never reached the over state",
2763
3625
  phase: "end",
2764
3626
  severity: "warning"
@@ -2835,12 +3697,11 @@ var init_decisions = __esm({
2835
3697
  });
2836
3698
 
2837
3699
  // src/live/phases.ts
2838
- var END_BUDGET_MS, RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep3, jitterX, jitterY, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
3700
+ var RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep4, jitterX, jitterY, ladderFor, execStep, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
2839
3701
  var init_phases = __esm({
2840
3702
  "src/live/phases.ts"() {
2841
3703
  "use strict";
2842
3704
  init_decisions();
2843
- END_BUDGET_MS = 45e3;
2844
3705
  RETRY_NAV_MS = 8e3;
2845
3706
  PLAY_STEPS = 7;
2846
3707
  PLAY_STEP_MS = 850;
@@ -2853,11 +3714,98 @@ var init_phases = __esm({
2853
3714
  STABILITY_CYCLES = 2;
2854
3715
  START_BURST_TAPS = 3;
2855
3716
  DESKTOP_FPS_MS = 2e3;
2856
- sleep3 = (ms) => new Promise((resolve2) => {
3717
+ sleep4 = (ms) => new Promise((resolve2) => {
2857
3718
  setTimeout(resolve2, ms);
2858
3719
  });
2859
3720
  jitterX = (step) => step * 37 % 121 - 60;
2860
3721
  jitterY = (step) => step * 53 % 181 - 90;
3722
+ ladderFor = (verb, viewport) => {
3723
+ const cx = Math.round(viewport.width / 2);
3724
+ const cy = Math.round(viewport.height / 2);
3725
+ const tap = (step) => ({
3726
+ kind: "tap",
3727
+ x: Math.round(cx + jitterX(step)),
3728
+ y: Math.round(cy + jitterY(step))
3729
+ });
3730
+ const sweep = (step) => {
3731
+ const x = Math.round(cx + jitterX(step));
3732
+ const y = Math.round(cy + jitterY(step));
3733
+ return { kind: "drag", x1: x - DRAG_SPAN, x2: x + DRAG_SPAN, y1: y, y2: y };
3734
+ };
3735
+ const base = () => Array.from({ length: PLAY_STEPS }, (_, step) => {
3736
+ if (step === HOLD_STEP_INDEX) {
3737
+ return { kind: "hold", ms: HOLD_MS, x: tap(step).x, y: tap(step).y };
3738
+ }
3739
+ if (step === DRAG_STEP_INDEX) return sweep(step);
3740
+ return tap(step);
3741
+ });
3742
+ switch (verb) {
3743
+ case "swap": {
3744
+ const gap = 34;
3745
+ return [
3746
+ { kind: "tap", x: cx - gap, y: Math.round(cy + jitterY(0)) },
3747
+ { kind: "tap", x: cx + gap, y: Math.round(cy + jitterY(0)) },
3748
+ { kind: "tap", x: Math.round(cx + jitterX(2)), y: cy - gap },
3749
+ { kind: "tap", x: Math.round(cx + jitterX(2)), y: cy + gap },
3750
+ { kind: "tap", x: cx - gap, y: Math.round(cy + jitterY(4)) },
3751
+ { kind: "tap", x: cx + gap, y: Math.round(cy + jitterY(4)) },
3752
+ sweep(0),
3753
+ { kind: "drag", x1: cx, x2: cx, y1: cy + DRAG_SPAN, y2: cy - DRAG_SPAN }
3754
+ ];
3755
+ }
3756
+ case "place": {
3757
+ return [
3758
+ { kind: "tap", x: cx - 80, y: cy - 60 },
3759
+ { kind: "tap", x: cx + 40, y: cy + 20 },
3760
+ { kind: "tap", x: cx + 80, y: cy - 40 },
3761
+ { kind: "tap", x: cx - 30, y: cy + 70 },
3762
+ { kind: "tap", x: Math.round(cx + jitterX(4)), y: cy },
3763
+ { kind: "tap", x: cx - 70, y: cy + 80 },
3764
+ { kind: "drag", x1: cx - 60, x2: cx + 60, y1: cy, y2: cy }
3765
+ ];
3766
+ }
3767
+ case "type": {
3768
+ return [
3769
+ { kind: "type", text: "frogoe" },
3770
+ tap(0),
3771
+ { kind: "type", text: "glow" },
3772
+ tap(2),
3773
+ { kind: "hold", ms: HOLD_MS, x: tap(3).x, y: tap(3).y },
3774
+ sweep(5),
3775
+ { kind: "type", text: "game" },
3776
+ tap(6)
3777
+ ];
3778
+ }
3779
+ case "draw": {
3780
+ return [
3781
+ { kind: "drag", x1: cx - 90, x2: cx + 90, y1: cy - 50, y2: cy + 30 },
3782
+ { kind: "tap", x: cx + 60, y: cy - 60 },
3783
+ { kind: "drag", x1: cx + 80, x2: cx - 70, y1: cy - 20, y2: cy + 70 },
3784
+ { kind: "hold", ms: HOLD_MS, x: cx, y: cy },
3785
+ { kind: "drag", x1: cx - 40, x2: cx + 70, y1: cy + 90, y2: cy - 90 },
3786
+ { kind: "tap", x: cx - 60, y: cy + 20 }
3787
+ ];
3788
+ }
3789
+ default:
3790
+ return base();
3791
+ }
3792
+ };
3793
+ execStep = async (driver, step) => {
3794
+ switch (step.kind) {
3795
+ case "tap":
3796
+ await driver.tap(step.x, step.y);
3797
+ break;
3798
+ case "hold":
3799
+ await driver.hold(step.x, step.y, step.ms);
3800
+ break;
3801
+ case "drag":
3802
+ await driver.drag(step.x1, step.y1, step.x2, step.y2);
3803
+ break;
3804
+ case "type":
3805
+ await driver.type(step.text);
3806
+ break;
3807
+ }
3808
+ };
2861
3809
  hasError = (findings) => findings.some((f) => f.severity === "error");
2862
3810
  runBootChecks = async (driver, ctx) => {
2863
3811
  const findings = [];
@@ -2900,7 +3848,7 @@ var init_phases = __esm({
2900
3848
  return findings;
2901
3849
  };
2902
3850
  runDesktopPass = async (driver, ctx) => {
2903
- const doSleep = ctx.sleep ?? sleep3;
3851
+ const doSleep = ctx.sleep ?? sleep4;
2904
3852
  const findings = await runBootChecks(driver, ctx);
2905
3853
  if (hasError(findings)) {
2906
3854
  return { findings };
@@ -2921,8 +3869,8 @@ var init_phases = __esm({
2921
3869
  };
2922
3870
  overShotName = (cycle) => cycle === 0 ? "live-mobile-over.png" : `live-mobile-over-${cycle + 1}.png`;
2923
3871
  retryShotName = (cycle) => cycle === 0 ? "live-mobile-retry.png" : `live-mobile-retry-${cycle + 1}.png`;
2924
- waitForOver = async (driver, doSleep) => {
2925
- for (let waited = 0; waited < END_BUDGET_MS; waited += POLL_MS) {
3872
+ waitForOver = async (driver, doSleep, budgetMs) => {
3873
+ for (let waited = 0; waited < budgetMs; waited += POLL_MS) {
2926
3874
  await doSleep(POLL_MS);
2927
3875
  if (await driver.gameState() === "over") {
2928
3876
  return true;
@@ -2931,7 +3879,7 @@ var init_phases = __esm({
2931
3879
  return false;
2932
3880
  };
2933
3881
  runStartBurst = async (driver, ctx) => {
2934
- const doSleep = ctx.sleep ?? sleep3;
3882
+ const doSleep = ctx.sleep ?? sleep4;
2935
3883
  for (let step = 0; step < START_BURST_TAPS; step++) {
2936
3884
  const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
2937
3885
  const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
@@ -2940,7 +3888,7 @@ var init_phases = __esm({
2940
3888
  }
2941
3889
  };
2942
3890
  verifyDeath = async (driver, ctx, findings, cycle) => {
2943
- const doSleep = ctx.sleep ?? sleep3;
3891
+ const doSleep = ctx.sleep ?? sleep4;
2944
3892
  let events = await driver.finishEvents();
2945
3893
  if (events.length === 0) {
2946
3894
  await doSleep(GRACE_MS);
@@ -2961,7 +3909,7 @@ var init_phases = __esm({
2961
3909
  return presence.retry;
2962
3910
  };
2963
3911
  runLifecycle = async (driver, ctx) => {
2964
- const doSleep = ctx.sleep ?? sleep3;
3912
+ const doSleep = ctx.sleep ?? sleep4;
2965
3913
  const settle = ctx.settleMs ?? 2e3;
2966
3914
  const name = ctx.viewport.name;
2967
3915
  const findings = [];
@@ -2971,6 +3919,7 @@ var init_phases = __esm({
2971
3919
  if (hasError(boot)) {
2972
3920
  return { findings, lifecycle: { ends: false, retryReloads: 0 }, playability: "no-input" };
2973
3921
  }
3922
+ const ladder = ladderFor(ctx.verb ?? "tap", ctx.viewport);
2974
3923
  const mark = await driver.fpsMark();
2975
3924
  const hashes = [];
2976
3925
  let streak = 0;
@@ -2979,16 +3928,8 @@ var init_phases = __esm({
2979
3928
  let sawPaused = false;
2980
3929
  let sawStuck = false;
2981
3930
  let corrupt = null;
2982
- for (let step = 0; step < PLAY_STEPS; step++) {
2983
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
2984
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
2985
- if (step === HOLD_STEP_INDEX) {
2986
- await driver.hold(x, y, HOLD_MS);
2987
- } else if (step === DRAG_STEP_INDEX) {
2988
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
2989
- } else {
2990
- await driver.tap(x, y);
2991
- }
3931
+ for (const step of ladder) {
3932
+ await execStep(driver, step);
2992
3933
  await doSleep(PLAY_STEP_MS);
2993
3934
  const state = await driver.gameState();
2994
3935
  if (state === "over") {
@@ -3056,13 +3997,17 @@ var init_phases = __esm({
3056
3997
  findings.push(audioFinding);
3057
3998
  }
3058
3999
  }
4000
+ const session = ctx.session ?? "blitz";
4001
+ const budget = endBudgetMs(session);
3059
4002
  let ends = false;
3060
4003
  let retryReloads = 0;
3061
- if (!sawOver) {
3062
- sawOver = await waitForOver(driver, doSleep);
4004
+ if (!sawOver && budget > 0) {
4005
+ sawOver = await waitForOver(driver, doSleep, budget);
3063
4006
  }
3064
4007
  if (!sawOver) {
3065
- findings.push(neverEndsFinding(END_BUDGET_MS));
4008
+ if (warnsWhenItNeverEnds(session)) {
4009
+ findings.push(neverEndsFinding(END_BUDGET_MS));
4010
+ }
3066
4011
  } else {
3067
4012
  ends = true;
3068
4013
  let canRetry = await verifyDeath(driver, ctx, findings, 0);
@@ -3089,7 +4034,7 @@ var init_phases = __esm({
3089
4034
  await ctx.shot?.(retryShotName(cycle));
3090
4035
  if (cycle < STABILITY_CYCLES - 1) {
3091
4036
  await runStartBurst(driver, ctx);
3092
- const overAgain = await waitForOver(driver, doSleep);
4037
+ const overAgain = await waitForOver(driver, doSleep, END_BUDGET_MS);
3093
4038
  if (!overAgain) {
3094
4039
  findings.push(neverEndsFinding(END_BUDGET_MS));
3095
4040
  break;
@@ -3101,16 +4046,8 @@ var init_phases = __esm({
3101
4046
  const throttleMark = await driver.fpsMark();
3102
4047
  await driver.setCpuThrottling(THROTTLE_RATE);
3103
4048
  await runStartBurst(driver, ctx);
3104
- for (let step = 0; step < PLAY_STEPS; step++) {
3105
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
3106
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
3107
- if (step === HOLD_STEP_INDEX) {
3108
- await driver.hold(x, y, HOLD_MS);
3109
- } else if (step === DRAG_STEP_INDEX) {
3110
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
3111
- } else {
3112
- await driver.tap(x, y);
3113
- }
4049
+ for (const step of ladder) {
4050
+ await execStep(driver, step);
3114
4051
  await doSleep(PLAY_STEP_MS);
3115
4052
  }
3116
4053
  const throttledBuckets = await driver.fpsSince(throttleMark);
@@ -3134,19 +4071,32 @@ var live_exports = {};
3134
4071
  __export(live_exports, {
3135
4072
  collectLive: () => collectLive
3136
4073
  });
3137
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync4 } from "fs";
3138
- import path12 from "path";
3139
- var VIEWPORTS, waitForServer, collectLive;
4074
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
4075
+ import path15 from "path";
4076
+ var VIEWPORTS, declaredIntent, waitForServer, collectLive;
3140
4077
  var init_live = __esm({
3141
4078
  "src/live/index.ts"() {
3142
4079
  "use strict";
3143
- init_browser();
4080
+ init_src();
4081
+ init_launch();
4082
+ init_manager();
3144
4083
  init_driver();
3145
4084
  init_phases();
3146
4085
  VIEWPORTS = [
3147
4086
  { height: 844, name: "mobile", width: 390 },
3148
4087
  { height: 800, name: "desktop", width: 1280 }
3149
4088
  ];
4089
+ declaredIntent = (dir) => {
4090
+ try {
4091
+ const brief = parseBrief(readFileSync10(path15.join(dir, "BRIEF.md"), "utf-8"));
4092
+ return {
4093
+ session: brief?.session !== void 0 && SESSIONS.includes(brief.session) ? brief.session : "blitz",
4094
+ verb: brief?.verb !== void 0 && VERBS.includes(brief.verb) ? brief.verb : "tap"
4095
+ };
4096
+ } catch {
4097
+ return { session: "blitz", verb: "tap" };
4098
+ }
4099
+ };
3150
4100
  waitForServer = async (url) => {
3151
4101
  for (let attempt = 0; attempt < 10; attempt++) {
3152
4102
  try {
@@ -3156,11 +4106,11 @@ var init_live = __esm({
3156
4106
  }
3157
4107
  } catch {
3158
4108
  }
3159
- await sleep3(500);
4109
+ await sleep4(500);
3160
4110
  }
3161
4111
  };
3162
4112
  collectLive = async (options) => {
3163
- const dir = path12.resolve(options.dir);
4113
+ const dir = path15.resolve(options.dir);
3164
4114
  const settle = options.settleMs ?? 2e3;
3165
4115
  const findings = [];
3166
4116
  const screenshots = [];
@@ -3170,16 +4120,10 @@ var init_live = __esm({
3170
4120
  };
3171
4121
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
3172
4122
  const server = await startServer2(dir);
3173
- const snapshotDir = path12.join(dir, "snapshots");
3174
- mkdirSync6(snapshotDir, { recursive: true });
3175
- const { default: puppeteer } = await import("puppeteer-core");
3176
- const executablePath = await ensureBrowser();
3177
- const browser = await puppeteer.launch({
3178
- args: ["--no-sandbox", "--disable-gpu"],
3179
- defaultViewport: null,
3180
- executablePath,
3181
- headless: true
3182
- });
4123
+ const snapshotDir = path15.join(dir, "snapshots");
4124
+ mkdirSync10(snapshotDir, { recursive: true });
4125
+ const intent = declaredIntent(dir);
4126
+ const browser = await launchBrowser({ legacyDirs: [legacyCacheDir(dir)] });
3183
4127
  try {
3184
4128
  let serverReady = false;
3185
4129
  for (const viewport of VIEWPORTS) {
@@ -3191,19 +4135,21 @@ var init_live = __esm({
3191
4135
  size: { height: viewport2.height, width: viewport2.width }
3192
4136
  });
3193
4137
  const shot = async (name) => {
3194
- writeFileSync4(path12.join(snapshotDir, name), await driver.screenshot());
3195
- screenshots.push(path12.join("snapshots", name));
4138
+ writeFileSync6(path15.join(snapshotDir, name), await driver.screenshot());
4139
+ screenshots.push(path15.join("snapshots", name));
3196
4140
  };
3197
4141
  if (!serverReady) {
3198
4142
  await waitForServer(server.urls.local);
3199
4143
  serverReady = true;
3200
4144
  }
3201
4145
  await page.goto(server.urls.local, { timeout: 15e3, waitUntil: "domcontentloaded" });
3202
- await sleep3(settle);
4146
+ await sleep4(settle);
3203
4147
  if (viewport2.name === "mobile") {
3204
4148
  const outcome = await runLifecycle(driver, {
3205
4149
  settleMs: settle,
3206
4150
  shot,
4151
+ verb: intent.verb,
4152
+ session: intent.session,
3207
4153
  viewport: viewport2
3208
4154
  });
3209
4155
  findings.push(...outcome.findings);
@@ -3627,9 +4573,9 @@ var init_compose = __esm({
3627
4573
  });
3628
4574
 
3629
4575
  // src/manifest.ts
3630
- import { createHash as createHash2 } from "crypto";
3631
- import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
3632
- import path13 from "path";
4576
+ import { createHash as createHash3 } from "crypto";
4577
+ import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
4578
+ import path16 from "path";
3633
4579
  var DESCRIPTION_CAP, descriptionFrom, buildManifest;
3634
4580
  var init_manifest = __esm({
3635
4581
  "src/manifest.ts"() {
@@ -3648,16 +4594,16 @@ var init_manifest = __esm({
3648
4594
  return null;
3649
4595
  };
3650
4596
  buildManifest = (options) => {
3651
- const dir = path13.resolve(options.dir);
3652
- const briefSource = readFileSync9(path13.join(dir, "BRIEF.md"), "utf-8");
4597
+ const dir = path16.resolve(options.dir);
4598
+ const briefSource = readFileSync11(path16.join(dir, "BRIEF.md"), "utf-8");
3653
4599
  const brief = parseBrief(briefSource);
3654
4600
  if (!brief?.title) return null;
3655
- const pin = existsSync8(path13.join(dir, "frogoe.json")) ? JSON.parse(readFileSync9(path13.join(dir, "frogoe.json"), "utf-8")).contract ?? "0.1.0" : "0.1.0";
3656
- const artifact = options.artifactHtml ?? readFileSync9(path13.join(dir, "dist", "index.html"), "utf-8");
3657
- const sha2562 = createHash2("sha256").update(artifact, "utf-8").digest("hex");
4601
+ const pin = existsSync12(path16.join(dir, "frogoe.json")) ? JSON.parse(readFileSync11(path16.join(dir, "frogoe.json"), "utf-8")).contract ?? "0.1.0" : "0.1.0";
4602
+ const artifact = options.artifactHtml ?? readFileSync11(path16.join(dir, "dist", "index.html"), "utf-8");
4603
+ const sha2562 = createHash3("sha256").update(artifact, "utf-8").digest("hex");
3658
4604
  const mediaSha = (file) => {
3659
- const full = path13.join(dir, "dist", "assets", file);
3660
- return existsSync8(full) ? createHash2("sha256").update(readFileSync9(full)).digest("hex") : null;
4605
+ const full = path16.join(dir, "dist", "assets", file);
4606
+ return existsSync12(full) ? createHash3("sha256").update(readFileSync11(full)).digest("hex") : null;
3661
4607
  };
3662
4608
  return {
3663
4609
  artifact: "index.html",
@@ -3666,7 +4612,7 @@ var init_manifest = __esm({
3666
4612
  description: descriptionFrom(briefSource),
3667
4613
  entry: "index.html",
3668
4614
  fonts: brief.fonts ?? null,
3669
- icon: existsSync8(path13.join(dir, "dist", "assets", "icon.png")) ? "assets/icon.png" : null,
4615
+ icon: existsSync12(path16.join(dir, "dist", "assets", "icon.png")) ? "assets/icon.png" : null,
3670
4616
  iconSha256: mediaSha("icon.png"),
3671
4617
  mood: brief.mood ?? null,
3672
4618
  palette: {
@@ -3675,8 +4621,9 @@ var init_manifest = __esm({
3675
4621
  fg: brief.fg ?? "",
3676
4622
  ...brief.outline ? { outline: brief.outline } : {}
3677
4623
  },
3678
- poster: existsSync8(path13.join(dir, "dist", "assets", "poster.png")) ? "assets/poster.png" : null,
4624
+ poster: existsSync12(path16.join(dir, "dist", "assets", "poster.png")) ? "assets/poster.png" : null,
3679
4625
  posterSha256: mediaSha("poster.png"),
4626
+ session: brief.session !== void 0 && SESSIONS.includes(brief.session) ? brief.session : "blitz",
3680
4627
  title: brief.title,
3681
4628
  verb: brief.verb ?? "tap"
3682
4629
  };
@@ -3689,8 +4636,8 @@ var embed_exports = {};
3689
4636
  __export(embed_exports, {
3690
4637
  command: () => command4
3691
4638
  });
3692
- import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync5 } from "fs";
3693
- import path14 from "path";
4639
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
4640
+ import path17 from "path";
3694
4641
  import { defineCommand as defineCommand4 } from "citty";
3695
4642
  var dataUri2, command4;
3696
4643
  var init_embed = __esm({
@@ -3700,15 +4647,15 @@ var init_embed = __esm({
3700
4647
  init_card();
3701
4648
  init_compose();
3702
4649
  init_manifest();
3703
- dataUri2 = (file) => existsSync9(file) ? `data:image/png;base64,${readFileSync10(file).toString("base64")}` : null;
4650
+ dataUri2 = (file) => existsSync13(file) ? `data:image/png;base64,${readFileSync12(file).toString("base64")}` : null;
3704
4651
  command4 = defineCommand4({
3705
4652
  args: {
3706
4653
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
3707
4654
  },
3708
4655
  async run({ args }) {
3709
4656
  const dir = args.dir ? String(args.dir) : process.cwd();
3710
- const artifactPath = path14.join(dir, "dist", "index.html");
3711
- if (!existsSync9(artifactPath)) {
4657
+ const artifactPath = path17.join(dir, "dist", "index.html");
4658
+ if (!existsSync13(artifactPath)) {
3712
4659
  throw new Error(
3713
4660
  "frogoe embed: no dist/index.html \u2014 run `frogoe bundle` first (and `frogoe check` before it: check \u2192 bundle \u2192 embed)"
3714
4661
  );
@@ -3724,20 +4671,20 @@ var init_embed = __esm({
3724
4671
  if (!manifest) {
3725
4672
  throw new Error("frogoe embed: could not build the manifest from BRIEF.md");
3726
4673
  }
3727
- const posterDataUri = dataUri2(path14.join(dir, "dist", "assets", "poster.png"));
3728
- const iconDataUri = dataUri2(path14.join(dir, "dist", "assets", "icon.png"));
3729
- const gameHtml = readFileSync10(artifactPath, "utf-8");
4674
+ const posterDataUri = dataUri2(path17.join(dir, "dist", "assets", "poster.png"));
4675
+ const iconDataUri = dataUri2(path17.join(dir, "dist", "assets", "icon.png"));
4676
+ const gameHtml = readFileSync12(artifactPath, "utf-8");
3730
4677
  const { payloadB64 } = composePayload(gameHtml);
3731
4678
  const html = composeEmbedHtml({ iconDataUri, manifest, payloadB64, posterDataUri });
3732
- mkdirSync7(path14.join(dir, "dist"), { recursive: true });
3733
- writeFileSync5(
3734
- path14.join(dir, "dist", "manifest.json"),
4679
+ mkdirSync11(path17.join(dir, "dist"), { recursive: true });
4680
+ writeFileSync7(
4681
+ path17.join(dir, "dist", "manifest.json"),
3735
4682
  `${JSON.stringify(manifest, null, 2)}
3736
4683
  `,
3737
4684
  "utf-8"
3738
4685
  );
3739
- const outPath = path14.join(dir, "dist", "embed.html");
3740
- writeFileSync5(outPath, html, "utf-8");
4686
+ const outPath = path17.join(dir, "dist", "embed.html");
4687
+ writeFileSync7(outPath, html, "utf-8");
3741
4688
  console.log(` frogoe embed \u2192 ${outPath}`);
3742
4689
  console.log(
3743
4690
  ` ${html.length.toLocaleString("en-US")} bytes \xB7 sandbox allow-scripts \xB7 sha256 ${manifest.artifactSha256.slice(0, 12)}`
@@ -3864,17 +4811,18 @@ var vision_exports = {};
3864
4811
  __export(vision_exports, {
3865
4812
  command: () => command5
3866
4813
  });
3867
- import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
3868
- import path15 from "path";
4814
+ import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
4815
+ import path18 from "path";
3869
4816
  import { defineCommand as defineCommand5 } from "citty";
3870
4817
  var HELPERS, PAGE_SCRIPT, show, metricLine, command5;
3871
4818
  var init_vision = __esm({
3872
4819
  "src/commands/vision.ts"() {
3873
4820
  "use strict";
3874
4821
  init_src();
4822
+ init_launch();
4823
+ init_manager();
3875
4824
  init_art_eyes();
3876
4825
  init_art_verify();
3877
- init_browser();
3878
4826
  HELPERS = [
3879
4827
  luminance,
3880
4828
  contrastRatio2,
@@ -3984,14 +4932,14 @@ var init_vision = __esm({
3984
4932
  objects: { type: "boolean", description: "SPRITES objects only" }
3985
4933
  },
3986
4934
  async run({ args }) {
3987
- const dir = path15.resolve(args.dir ? String(args.dir) : process.cwd());
3988
- const briefPath = path15.join(dir, "BRIEF.md");
3989
- if (!existsSync10(briefPath)) {
4935
+ const dir = path18.resolve(args.dir ? String(args.dir) : process.cwd());
4936
+ const briefPath = path18.join(dir, "BRIEF.md");
4937
+ if (!existsSync14(briefPath)) {
3990
4938
  throw new Error(
3991
4939
  "frogoe vision: no BRIEF.md in this folder \u2014 run this from a game (frogoe init)"
3992
4940
  );
3993
4941
  }
3994
- const brief = parseBrief(readFileSync11(briefPath, "utf-8"));
4942
+ const brief = parseBrief(readFileSync13(briefPath, "utf-8"));
3995
4943
  if (!brief) {
3996
4944
  throw new Error("frogoe vision: BRIEF.md is present but unparsable \u2014 fill the frontmatter");
3997
4945
  }
@@ -4011,12 +4959,9 @@ var init_vision = __esm({
4011
4959
  const compact = !wantAll;
4012
4960
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
4013
4961
  const server = await startServer2(dir);
4014
- const { default: puppeteer } = await import("puppeteer-core");
4015
- const browser = await puppeteer.launch({
4016
- args: ["--no-sandbox", "--disable-gpu"],
4962
+ const browser = await launchBrowser({
4017
4963
  defaultViewport: { height: 844, width: 390 },
4018
- executablePath: await ensureBrowser(),
4019
- headless: true
4964
+ legacyDirs: [legacyCacheDir(dir)]
4020
4965
  });
4021
4966
  let report;
4022
4967
  try {
@@ -4101,7 +5046,7 @@ var init_vision = __esm({
4101
5046
  console.log(show(icon, pretty));
4102
5047
  }
4103
5048
  }
4104
- if (!targeted && !existsSync10(path15.join(dir, "assets", "poster.js"))) {
5049
+ if (!targeted && !existsSync14(path18.join(dir, "assets", "poster.js"))) {
4105
5050
  console.log("\n(note: run `frogoe check` first \u2014 vision only looks, it never gates)");
4106
5051
  }
4107
5052
  },
@@ -4183,7 +5128,7 @@ var report_exports = {};
4183
5128
  __export(report_exports, {
4184
5129
  command: () => command8
4185
5130
  });
4186
- import { readFileSync as readFileSync12 } from "fs";
5131
+ import { readFileSync as readFileSync14 } from "fs";
4187
5132
  import { defineCommand as defineCommand8 } from "citty";
4188
5133
  var command8;
4189
5134
  var init_report = __esm({
@@ -4202,7 +5147,7 @@ var init_report = __esm({
4202
5147
  console.log(`frogoe report: no sessions in ${dir} \u2014 play a run under \`frogoe run\` first`);
4203
5148
  return;
4204
5149
  }
4205
- const records = readFileSync12(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
5150
+ const records = readFileSync14(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
4206
5151
  const s = summarizeRecords(records);
4207
5152
  console.log(`
4208
5153
  frogoe report \u2014 ${file}`);
@@ -4240,8 +5185,8 @@ var init_firewall = __esm({
4240
5185
  return null;
4241
5186
  }
4242
5187
  };
4243
- binaryBlocked = (path17) => {
4244
- const out = run(`--getappblocked "${path17}"`);
5188
+ binaryBlocked = (path19) => {
5189
+ const out = run(`--getappblocked "${path19}"`);
4245
5190
  if (out === null) return null;
4246
5191
  if (/blocked/iu.test(out)) return true;
4247
5192
  if (/allowed/iu.test(out)) return false;
@@ -4297,224 +5242,6 @@ var init_plan = __esm({
4297
5242
  }
4298
5243
  });
4299
5244
 
4300
- // src/net/tunnel.ts
4301
- import { spawn, spawnSync } from "child_process";
4302
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, chmodSync, writeFileSync as writeFileSync6 } from "fs";
4303
- import os3 from "os";
4304
- import path16 from "path";
4305
- import { gunzipSync } from "zlib";
4306
- var URL_PATTERN, parseTunnelUrl, TAG_PATTERN, assertSafeTag, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
4307
- var init_tunnel = __esm({
4308
- "src/net/tunnel.ts"() {
4309
- "use strict";
4310
- URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/iu;
4311
- parseTunnelUrl = (chunk) => URL_PATTERN.exec(chunk)?.[0];
4312
- TAG_PATTERN = /^\d{4}\.\d+\.\d+(-[A-Za-z0-9.]+)?$/u;
4313
- assertSafeTag = (tag) => {
4314
- if (!TAG_PATTERN.test(tag)) {
4315
- throw new Error(
4316
- `cloudflared version "${tag}" is not a valid release tag (expected e.g. 2025.10.1)`
4317
- );
4318
- }
4319
- return tag;
4320
- };
4321
- octalAt = (block, offset, length) => {
4322
- const raw = block.subarray(offset, offset + length).toString("utf-8");
4323
- const digits = raw.replace(/[\0 ]/gu, "");
4324
- return digits.length === 0 ? 0 : Number.parseInt(digits, 8);
4325
- };
4326
- extractSingleFile = (tar, wanted) => {
4327
- for (let offset = 0; offset + 512 <= tar.length; ) {
4328
- const header = tar.subarray(offset, offset + 512);
4329
- const name = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/u, "");
4330
- const size = octalAt(header, 124, 12);
4331
- const type = header.toString("utf-8").charCodeAt(156);
4332
- const dataStart = offset + 512;
4333
- const dataEnd = dataStart + size;
4334
- if (dataEnd > tar.length) return null;
4335
- if (name === wanted && (type === 48 || type === 0)) {
4336
- return tar.subarray(dataStart, dataEnd);
4337
- }
4338
- offset = dataStart + Math.ceil(size / 512) * 512;
4339
- }
4340
- return null;
4341
- };
4342
- ENV_PIN = "FROGOE_CLOUDFLARED_VERSION";
4343
- assetName = (platform, arch) => {
4344
- const a = arch === "arm64" ? "arm64" : arch === "x64" ? "amd64" : arch === "ia32" ? "386" : null;
4345
- if (!a) return null;
4346
- switch (platform) {
4347
- case "darwin":
4348
- return a === "386" ? null : `cloudflared-darwin-${a}.tgz`;
4349
- case "linux":
4350
- return `cloudflared-linux-${a}`;
4351
- // bare binaries
4352
- case "win32":
4353
- return a === "arm64" ? null : `cloudflared-windows-${a}.exe`;
4354
- // bare executables
4355
- default:
4356
- return null;
4357
- }
4358
- };
4359
- binaryFileName = (platform) => platform === "win32" ? "cloudflared.exe" : "cloudflared";
4360
- cacheBase = (platform, env, home) => {
4361
- if (platform === "darwin") return path16.join(home, "Library", "Caches");
4362
- if (platform === "win32") return env.LOCALAPPDATA ?? path16.join(home, "AppData", "Local");
4363
- return path16.join(home, ".cache");
4364
- };
4365
- resolveLatestTag = async () => {
4366
- const res = await fetch("https://api.github.com/repos/cloudflare/cloudflared/releases/latest", {
4367
- headers: { accept: "application/vnd.github+json" }
4368
- });
4369
- if (!res.ok) throw new Error(`github api ${res.status}`);
4370
- const body = await res.json();
4371
- if (!body.tag_name) throw new Error("github api returned no tag_name");
4372
- return body.tag_name;
4373
- };
4374
- mb = (bytes) => (bytes / (1024 * 1024)).toFixed(0);
4375
- downloadWithProgress = async (res, onChunk) => {
4376
- const total = Number(res.headers.get("content-length") ?? 0);
4377
- const reader = res.body?.getReader();
4378
- if (!reader) return Buffer.from(await res.arrayBuffer());
4379
- const chunks = [];
4380
- let done = 0;
4381
- let nextReport = 0;
4382
- for (; ; ) {
4383
- const step = await reader.read();
4384
- if (step.done) break;
4385
- chunks.push(Buffer.from(step.value));
4386
- done += step.value.byteLength;
4387
- if (done >= nextReport) {
4388
- onChunk(done, total);
4389
- nextReport = done + 5 * 1024 * 1024;
4390
- }
4391
- }
4392
- return Buffer.concat(chunks);
4393
- };
4394
- binaryEchoes = (bin, tag) => {
4395
- try {
4396
- const probe = spawnSync(bin, ["--version"], { encoding: "utf-8", timeout: 1e4 });
4397
- const out = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
4398
- return probe.status === 0 && out.includes(tag);
4399
- } catch {
4400
- return false;
4401
- }
4402
- };
4403
- resolveBinary = async (onProgress) => {
4404
- const pathProbe = spawnSync("cloudflared", ["--version"], { stdio: "ignore", timeout: 5e3 });
4405
- if (pathProbe.status === 0) return { downloaded: false, path: "cloudflared" };
4406
- const platform = process.platform;
4407
- const asset = assetName(platform, process.arch);
4408
- if (!asset) {
4409
- throw new Error(
4410
- `cloudflared publishes no build for ${platform}/${process.arch} \u2014 install it and put \`cloudflared\` on PATH`
4411
- );
4412
- }
4413
- const tag = process.env[ENV_PIN] ? assertSafeTag(process.env[ENV_PIN]) : assertSafeTag(await resolveLatestTag());
4414
- const root = path16.join(cacheBase(platform, process.env, os3.homedir()), "frogoe", "cloudflared");
4415
- const dir = path16.join(root, tag);
4416
- const bin = path16.join(dir, binaryFileName(platform));
4417
- const rootResolved = path16.resolve(root);
4418
- const binResolved = path16.resolve(bin);
4419
- if (!binResolved.startsWith(rootResolved + path16.sep)) {
4420
- throw new Error("cloudflared binary path escaped the frogoe cache \u2014 refusing to execute");
4421
- }
4422
- if (existsSync11(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
4423
- onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
4424
- const res = await fetch(
4425
- `https://github.com/cloudflare/cloudflared/releases/download/${tag}/${asset}`
4426
- );
4427
- if (!res.ok) throw new Error(`cloudflared ${tag} download failed (${res.status})`);
4428
- const raw = await downloadWithProgress(res, (done, total) => {
4429
- onProgress?.(`downloading cloudflared ${tag} \u2014 ${mb(done)}${total ? `/${mb(total)}` : ""} MB`);
4430
- });
4431
- const binary = asset.endsWith(".tgz") ? extractSingleFile(gunzipSync(raw), "cloudflared") : raw;
4432
- if (!binary) throw new Error("cloudflared archive did not contain the binary");
4433
- mkdirSync8(dir, { recursive: true });
4434
- writeFileSync6(bin, binary);
4435
- if (platform !== "win32") chmodSync(bin, 493);
4436
- if (!binaryEchoes(bin, tag)) {
4437
- throw new Error(
4438
- `cloudflared ${tag} failed its version check \u2014 deleted; set ${ENV_PIN} or install via brew`
4439
- );
4440
- }
4441
- return { downloaded: true, path: bin };
4442
- };
4443
- startTunnel = async (port, options) => {
4444
- const timeoutMs = options?.timeoutMs ?? 2e4;
4445
- const bin = await resolveBinary(options?.onProgress);
4446
- return new Promise((resolve2, reject) => {
4447
- const child = spawn(
4448
- bin.path,
4449
- ["tunnel", "--url", `http://localhost:${port}`, "--no-autoupdate"],
4450
- {
4451
- detached: true,
4452
- stdio: ["ignore", "pipe", "pipe"],
4453
- windowsHide: true
4454
- }
4455
- );
4456
- let settled = false;
4457
- let url;
4458
- let buffer = "";
4459
- const exited = new Promise((notify) => {
4460
- child.once("exit", () => notify());
4461
- });
4462
- const killTree = () => {
4463
- if (process.platform === "win32" || !child.pid) {
4464
- child.kill("SIGTERM");
4465
- return;
4466
- }
4467
- try {
4468
- process.kill(-child.pid, "SIGTERM");
4469
- } catch {
4470
- child.kill("SIGTERM");
4471
- }
4472
- };
4473
- const finish = (error) => {
4474
- if (settled) return;
4475
- settled = true;
4476
- clearTimeout(timer);
4477
- if (error || !url) {
4478
- killTree();
4479
- const tail = buffer.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).slice(-2).join(" | ");
4480
- const base = error?.message ?? "frogoe tunnel: cloudflared exited before producing a URL";
4481
- reject(new Error(tail ? `${base} \u2014 ${tail}` : base));
4482
- return;
4483
- }
4484
- resolve2({ exited, stop: killTree, url });
4485
- };
4486
- const timer = setTimeout(() => {
4487
- finish(
4488
- new Error(
4489
- `frogoe tunnel: no URL from cloudflared within ${timeoutMs / 1e3}s \u2014 check your internet, or brew install cloudflared`
4490
- )
4491
- );
4492
- }, timeoutMs);
4493
- child.once("exit", (code) => {
4494
- if (!settled) {
4495
- finish(new Error(`frogoe tunnel: cloudflared exited early (code ${code ?? "signal"})`));
4496
- }
4497
- });
4498
- const watch2 = (stream) => {
4499
- stream.on("data", (data) => {
4500
- if (settled && url) return;
4501
- buffer += data.toString("utf-8");
4502
- const found = parseTunnelUrl(buffer);
4503
- if (found && !url) {
4504
- url = found;
4505
- finish();
4506
- }
4507
- });
4508
- };
4509
- if (child.stderr && child.stdout) {
4510
- watch2(child.stderr);
4511
- watch2(child.stdout);
4512
- }
4513
- });
4514
- };
4515
- }
4516
- });
4517
-
4518
5245
  // src/commands/run.ts
4519
5246
  var run_exports2 = {};
4520
5247
  __export(run_exports2, {
@@ -4643,9 +5370,9 @@ var init_run2 = __esm({
4643
5370
 
4644
5371
  // src/utils/skillsManifest.ts
4645
5372
  import { execFile } from "child_process";
4646
- import { createHash as createHash3 } from "crypto";
4647
- import { existsSync as existsSync12, readdirSync as readdirSync3, readFileSync as readFileSync13, statSync as statSync3 } from "fs";
4648
- import { homedir } from "os";
5373
+ import { createHash as createHash4 } from "crypto";
5374
+ import { existsSync as existsSync15, readdirSync as readdirSync4, readFileSync as readFileSync15, statSync as statSync4 } from "fs";
5375
+ import { homedir as homedir2 } from "os";
4649
5376
  import { isAbsolute, join, relative, resolve, sep } from "path";
4650
5377
  import { promisify } from "util";
4651
5378
  function isCoreSkill(name) {
@@ -4654,10 +5381,10 @@ function isCoreSkill(name) {
4654
5381
  function listFilesSorted(dir) {
4655
5382
  const out = [];
4656
5383
  const walk = (d) => {
4657
- for (const name of readdirSync3(d)) {
5384
+ for (const name of readdirSync4(d)) {
4658
5385
  if (name === ".DS_Store") continue;
4659
5386
  const p = join(d, name);
4660
- if (statSync3(p).isDirectory()) walk(p);
5387
+ if (statSync4(p).isDirectory()) walk(p);
4661
5388
  else out.push(p);
4662
5389
  }
4663
5390
  };
@@ -4666,13 +5393,13 @@ function listFilesSorted(dir) {
4666
5393
  }
4667
5394
  function hashSkillBundle(skillDir) {
4668
5395
  const files = listFilesSorted(skillDir);
4669
- const h = createHash3("sha256");
5396
+ const h = createHash4("sha256");
4670
5397
  for (const f of files) {
4671
5398
  const rel = relative(skillDir, f).split(sep).join("/");
4672
5399
  h.update(rel);
4673
5400
  h.update("\0");
4674
5401
  const ext = rel.slice(rel.lastIndexOf("."));
4675
- const buf = readFileSync13(f);
5402
+ const buf = readFileSync15(f);
4676
5403
  if (TEXT_EXT.has(ext)) h.update(buf.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
4677
5404
  else h.update(buf);
4678
5405
  h.update("\0");
@@ -4680,7 +5407,7 @@ function hashSkillBundle(skillDir) {
4680
5407
  return { hash: h.digest("hex").slice(0, 16), files: files.length };
4681
5408
  }
4682
5409
  function buildManifest2(skillsRoot, meta) {
4683
- const names = readdirSync3(skillsRoot).filter((n) => existsSync12(join(skillsRoot, n, "SKILL.md"))).sort();
5410
+ const names = readdirSync4(skillsRoot).filter((n) => existsSync15(join(skillsRoot, n, "SKILL.md"))).sort();
4684
5411
  const skills = {};
4685
5412
  for (const name of names) skills[name] = hashSkillBundle(join(skillsRoot, name));
4686
5413
  return { source: meta.source, skills };
@@ -4696,7 +5423,7 @@ function agentFromDir(dir) {
4696
5423
  }
4697
5424
  function listSubdirs(dir) {
4698
5425
  try {
4699
- return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name);
5426
+ return readdirSync4(dir, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name);
4700
5427
  } catch {
4701
5428
  return [];
4702
5429
  }
@@ -4705,7 +5432,7 @@ function discoverSkillRoots(base, scope) {
4705
5432
  const candidates = [];
4706
5433
  const add = (hostBase, host) => {
4707
5434
  const dir = join(hostBase, host, "skills");
4708
- if (existsSync12(dir) && statSync3(dir).isDirectory())
5435
+ if (existsSync15(dir) && statSync4(dir).isDirectory())
4709
5436
  candidates.push({ dir, agent: agentLabel(host), scope });
4710
5437
  };
4711
5438
  for (const host of listSubdirs(base)) add(base, host);
@@ -4732,18 +5459,18 @@ function scopeForDir(dir, home, cwd) {
4732
5459
  }
4733
5460
  function locateInstall(skillNames, opts = {}) {
4734
5461
  if (opts.dir) {
4735
- return existsSync12(opts.dir) ? {
5462
+ return existsSync15(opts.dir) ? {
4736
5463
  dir: opts.dir,
4737
5464
  agent: agentFromDir(opts.dir),
4738
- scope: scopeForDir(opts.dir, opts.home ?? homedir(), opts.cwd ?? process.cwd())
5465
+ scope: scopeForDir(opts.dir, opts.home ?? homedir2(), opts.cwd ?? process.cwd())
4739
5466
  } : null;
4740
5467
  }
4741
5468
  const roots = [
4742
- ...discoverSkillRoots(opts.home ?? homedir(), "global"),
5469
+ ...discoverSkillRoots(opts.home ?? homedir2(), "global"),
4743
5470
  ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project")
4744
5471
  ];
4745
5472
  for (const root of roots) {
4746
- if (skillNames.some((n) => existsSync12(join(root.dir, n, "SKILL.md")))) return root;
5473
+ if (skillNames.some((n) => existsSync15(join(root.dir, n, "SKILL.md")))) return root;
4747
5474
  }
4748
5475
  return null;
4749
5476
  }
@@ -4751,7 +5478,7 @@ function hashInstalled(root, skillNames) {
4751
5478
  const out = {};
4752
5479
  for (const name of skillNames) {
4753
5480
  const skillDir = join(root.dir, name);
4754
- if (existsSync12(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
5481
+ if (existsSync15(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
4755
5482
  }
4756
5483
  return out;
4757
5484
  }
@@ -4788,7 +5515,7 @@ function findRepoManifest(cwd = process.cwd()) {
4788
5515
  let dir = cwd;
4789
5516
  for (let i = 0; i < 16; i++) {
4790
5517
  const p = join(dir, MANIFEST_FILE);
4791
- if (existsSync12(p)) return p;
5518
+ if (existsSync15(p)) return p;
4792
5519
  const parent = join(dir, "..");
4793
5520
  if (parent === dir) break;
4794
5521
  dir = parent;
@@ -4828,9 +5555,9 @@ async function remoteHeadSha(repoSlug) {
4828
5555
  }
4829
5556
  function resolveLocalManifest(source) {
4830
5557
  const direct = source.endsWith(".json") ? source : join(source, MANIFEST_FILE);
4831
- if (existsSync12(direct)) return JSON.parse(readFileSync13(direct, "utf8"));
5558
+ if (existsSync15(direct)) return JSON.parse(readFileSync15(direct, "utf8"));
4832
5559
  const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
4833
- if (existsSync12(skillsRoot)) return buildManifest2(skillsRoot, { source: skillsRoot });
5560
+ if (existsSync15(skillsRoot)) return buildManifest2(skillsRoot, { source: skillsRoot });
4834
5561
  throw new Error(`No skills manifest found at: ${source}`);
4835
5562
  }
4836
5563
  async function fetchRemoteManifest(source) {
@@ -4853,7 +5580,7 @@ async function resolveLatestManifest(source, cwd = process.cwd(), opts = {}) {
4853
5580
  }
4854
5581
  if (!source && !opts.canonical) {
4855
5582
  const repoManifest = findRepoManifest(cwd);
4856
- if (repoManifest) return JSON.parse(readFileSync13(repoManifest, "utf8"));
5583
+ if (repoManifest) return JSON.parse(readFileSync15(repoManifest, "utf8"));
4857
5584
  }
4858
5585
  return fetchRemoteManifest(source);
4859
5586
  }
@@ -5120,7 +5847,7 @@ import { defineCommand as defineCommand11, runMain } from "citty";
5120
5847
  // package.json
5121
5848
  var package_default = {
5122
5849
  name: "frogoe",
5123
- version: "0.5.5",
5850
+ version: "0.7.0",
5124
5851
  description: "froge CLI \u2014 the agent's hands: init, add, run, check, bundle",
5125
5852
  homepage: "https://github.com/frogoe/engine#readme",
5126
5853
  bugs: "https://github.com/frogoe/engine/issues",