@montytools/cli 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/monty.mjs +116 -27
  2. package/package.json +1 -1
package/bin/monty.mjs CHANGED
@@ -17,6 +17,8 @@ import { CATALOG, REGISTRIES } from "./catalog.mjs";
17
17
  const CONFIG_DIR = join(homedir(), ".monty");
18
18
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
19
19
  const DEFAULT_HOST = "https://usemonty.dev";
20
+ const DEV_SESSION_HEARTBEAT_MS = 30_000;
21
+ const DEV_SESSION_REQUEST_TIMEOUT_MS = 10_000;
20
22
  // Every app's source lives in one predictable place. `monty create` stamps
21
23
  // here by default (override with --dir) and `monty login` provisions it.
22
24
  const MONTY_HOME = join(homedir(), "Monty");
@@ -489,35 +491,69 @@ async function dev() {
489
491
  let ended = false;
490
492
  const buildFile = join(appDir, ".monty", "build");
491
493
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
494
+ // The DEV schema channel: heartbeats carry the compiled schema, and edits
495
+ // to monty.config.ts are re-compiled (softly) so schema changes reach the
496
+ // platform within one heartbeat. Publish owns the PROD schema.
497
+ let currentMeta = meta;
498
+ const configPath = join(appDir, "monty.config.ts");
499
+ let configMtime = statSync(configPath).mtimeMs;
500
+
501
+ async function refreshSchemaIfChanged() {
502
+ try {
503
+ const m = statSync(configPath).mtimeMs;
504
+ if (m === configMtime) return;
505
+ configMtime = m;
506
+ const fresh = await compileConfig(appDir, { soft: true });
507
+ if (fresh) {
508
+ currentMeta = fresh;
509
+ console.log(
510
+ `schema: monty.config.ts changed — dev schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
511
+ );
512
+ }
513
+ } catch { /* transient fs hiccup — next beat retries */ }
514
+ }
492
515
 
493
- async function endSession() {
494
- if (ended) return;
495
- ended = true;
496
- if (hbTimer) clearInterval(hbTimer);
497
- try { tunnelChild?.kill(); } catch { /* already gone */ }
516
+ async function clearDevSession(timeoutMs = 2000) {
498
517
  if (cfg?.key) {
499
518
  try {
500
519
  await fetch(`${host}/api/dev-session`, {
501
520
  method: "POST",
502
521
  headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
503
522
  body: JSON.stringify({ slug: meta.slug, end: true }),
504
- signal: AbortSignal.timeout(2000),
523
+ signal: AbortSignal.timeout(timeoutMs),
505
524
  });
506
525
  } catch { /* best effort */ }
507
526
  }
508
527
  }
509
528
 
529
+ async function endSession() {
530
+ if (ended) return;
531
+ ended = true;
532
+ if (hbTimer) clearInterval(hbTimer);
533
+ try { tunnelChild?.kill(); } catch { /* already gone */ }
534
+ await clearDevSession();
535
+ }
536
+
510
537
  async function heartbeat(originUrl) {
538
+ await refreshSchemaIfChanged();
511
539
  try {
512
540
  const r = await fetch(`${host}/api/dev-session`, {
513
541
  method: "POST",
514
542
  headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
515
- body: JSON.stringify({ slug: meta.slug, tunnelUrl: originUrl, name: meta.name, icon: meta.icon, buildId }),
543
+ body: JSON.stringify({
544
+ slug: meta.slug,
545
+ tunnelUrl: originUrl,
546
+ name: currentMeta.name,
547
+ icon: currentMeta.icon,
548
+ buildId,
549
+ schemaJson: currentMeta.schemaJson,
550
+ }),
551
+ signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
516
552
  });
517
553
  const data = await r.json().catch(() => null);
518
554
  if (!r.ok) {
519
555
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
520
- return;
556
+ return false;
521
557
  }
522
558
  if (data?.publishRequested && !publishing) {
523
559
  publishing = true;
@@ -538,7 +574,11 @@ async function dev() {
538
574
  });
539
575
  publishing = false;
540
576
  }
541
- } catch { /* transient network hiccup — next beat retries */ }
577
+ return true;
578
+ } catch {
579
+ console.log("dev-session: heartbeat failed — retrying on the next beat");
580
+ return false;
581
+ }
542
582
  }
543
583
 
544
584
  async function startDevSession() {
@@ -546,27 +586,60 @@ async function dev() {
546
586
  console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
547
587
  return;
548
588
  }
589
+ await clearDevSession();
549
590
  let originUrl = `http://localhost:${port}`;
591
+ let tunnelUpdate = Promise.resolve();
592
+ let tunnelVersion = 0;
593
+ async function activateTunnelUrl(url, { initial = false } = {}) {
594
+ const version = ++tunnelVersion;
595
+ if (!initial) {
596
+ console.log(`tunnel: changed to ${url}`);
597
+ await clearDevSession();
598
+ }
599
+ console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
600
+ const dnsLive = await waitForDns(url.replace("https://", ""));
601
+ if (ended || version !== tunnelVersion) return "superseded";
602
+ if (!dnsLive) {
603
+ if (initial) {
604
+ console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
605
+ } else {
606
+ console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
607
+ }
608
+ return "failed";
609
+ }
610
+ originUrl = url;
611
+ console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
612
+ if (!initial && !(await heartbeat(originUrl))) {
613
+ console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
614
+ }
615
+ return "activated";
616
+ }
617
+ const registerTunnelUrl = (url) => {
618
+ tunnelUpdate = tunnelUpdate.then(async () => {
619
+ if (ended || url === originUrl) return;
620
+ await activateTunnelUrl(url);
621
+ }).catch(() => {
622
+ console.log("tunnel: URL update failed — waiting for the next tunnel URL");
623
+ });
624
+ };
550
625
  if (!rest.includes("--no-tunnel")) {
551
626
  console.log("tunnel: starting (cloudflared quick tunnel)…");
552
- const t = await startTunnel(port);
627
+ const t = await startTunnel(port, registerTunnelUrl);
553
628
  tunnelChild = t.child;
554
629
  if (t.url) {
555
630
  console.log(`tunnel: ${t.url}`);
556
- console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
557
- if (await waitForDns(t.url.replace("https://", ""))) {
558
- originUrl = t.url;
559
- console.log("tunnel: DNS live");
560
- } else {
561
- console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
562
- }
631
+ await activateTunnelUrl(t.url, { initial: true });
563
632
  } else {
564
633
  console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
565
634
  }
566
635
  }
567
- await heartbeat(originUrl);
568
- console.log(`studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`);
569
- hbTimer = setInterval(() => void heartbeat(originUrl), 30_000);
636
+ const registered = await heartbeat(originUrl);
637
+ console.log(
638
+ registered
639
+ ? `studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`
640
+ : `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
641
+ );
642
+ hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
570
643
  }
571
644
 
572
645
  let announced = false;
@@ -600,6 +673,7 @@ async function resolvesVia(dohBase, hostname) {
600
673
  try {
601
674
  const r = await fetch(`${dohBase}?name=${hostname}&type=A`, {
602
675
  headers: { accept: "application/dns-json" },
676
+ signal: AbortSignal.timeout(3000),
603
677
  });
604
678
  const d = await r.json();
605
679
  return Array.isArray(d.Answer) && d.Answer.some((a) => a.type === 1);
@@ -629,7 +703,7 @@ async function waitForDns(hostname) {
629
703
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
630
704
  // binary on first use). Resolves with the public URL, or null on failure —
631
705
  // dev mode then falls back to localhost-only registration.
632
- function startTunnel(port) {
706
+ function startTunnel(port, onUrlChange) {
633
707
  return new Promise((resolve) => {
634
708
  let child;
635
709
  try {
@@ -646,12 +720,19 @@ function startTunnel(port) {
646
720
  resolve({ child, url: null });
647
721
  }
648
722
  }, 45_000);
723
+ let currentUrl = null;
649
724
  const scan = (chunk) => {
650
- const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(String(chunk));
651
- if (m && !settled) {
652
- settled = true;
653
- clearTimeout(timer);
654
- resolve({ child, url: m[0] });
725
+ const urls = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
726
+ for (const url of urls) {
727
+ if (url === currentUrl) continue;
728
+ currentUrl = url;
729
+ if (!settled) {
730
+ settled = true;
731
+ clearTimeout(timer);
732
+ resolve({ child, url });
733
+ } else {
734
+ onUrlChange?.(url);
735
+ }
655
736
  }
656
737
  };
657
738
  child.stdout.on("data", scan);
@@ -819,7 +900,7 @@ async function deploy() {
819
900
  console.log(`deployed: ${body.url} (version ${body.version})`);
820
901
  }
821
902
 
822
- async function compileConfig(appDir) {
903
+ async function compileConfig(appDir, { soft = false } = {}) {
823
904
  const { build } = await import("esbuild");
824
905
  const tmpDir = join(appDir, ".monty");
825
906
  mkdirSync(tmpDir, { recursive: true });
@@ -843,11 +924,19 @@ async function compileConfig(appDir) {
843
924
  });
844
925
  const result = spawnSync(process.execPath, [out], { encoding: "utf8" });
845
926
  if (result.status !== 0) {
927
+ if (soft) {
928
+ console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
929
+ return null;
930
+ }
846
931
  fail("CONFIG_COMPILE_FAILED", `monty.config.ts threw while loading:\n${result.stderr}\nFix the config (it must only call defineApp with zod tables).`);
847
932
  }
848
933
  return JSON.parse(result.stdout);
849
934
  } catch (e) {
850
935
  if (e?.errors) {
936
+ if (soft) {
937
+ console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
938
+ return null;
939
+ }
851
940
  fail("CONFIG_COMPILE_FAILED", `esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}`);
852
941
  }
853
942
  throw e;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "monty": "./bin/monty.mjs"