@montytools/cli 0.2.5 → 0.2.7
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/bin/monty.mjs +96 -27
- 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");
|
|
@@ -487,6 +489,7 @@ async function dev() {
|
|
|
487
489
|
let hbTimer = null;
|
|
488
490
|
let publishing = false;
|
|
489
491
|
let ended = false;
|
|
492
|
+
const sessionId = `dev_${randomBytes(16).toString("hex")}`;
|
|
490
493
|
const buildFile = join(appDir, ".monty", "build");
|
|
491
494
|
const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
|
|
492
495
|
// The DEV schema channel: heartbeats carry the compiled schema, and edits
|
|
@@ -511,24 +514,38 @@ async function dev() {
|
|
|
511
514
|
} catch { /* transient fs hiccup — next beat retries */ }
|
|
512
515
|
}
|
|
513
516
|
|
|
514
|
-
async function
|
|
515
|
-
if (ended) return;
|
|
516
|
-
ended = true;
|
|
517
|
-
if (hbTimer) clearInterval(hbTimer);
|
|
518
|
-
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
517
|
+
async function clearDevSession(timeoutMs = 2000) {
|
|
519
518
|
if (cfg?.key) {
|
|
520
519
|
try {
|
|
521
520
|
await fetch(`${host}/api/dev-session`, {
|
|
522
521
|
method: "POST",
|
|
523
522
|
headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
|
|
524
|
-
body: JSON.stringify({ slug: meta.slug, end: true }),
|
|
525
|
-
signal: AbortSignal.timeout(
|
|
523
|
+
body: JSON.stringify({ slug: meta.slug, sessionId, end: true }),
|
|
524
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
526
525
|
});
|
|
527
526
|
} catch { /* best effort */ }
|
|
528
527
|
}
|
|
529
528
|
}
|
|
530
529
|
|
|
531
|
-
async function
|
|
530
|
+
async function endSession() {
|
|
531
|
+
if (ended) return;
|
|
532
|
+
ended = true;
|
|
533
|
+
if (hbTimer) clearInterval(hbTimer);
|
|
534
|
+
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
535
|
+
await clearDevSession();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function stopSuperseded(fix) {
|
|
539
|
+
if (ended) return;
|
|
540
|
+
ended = true;
|
|
541
|
+
if (hbTimer) clearInterval(hbTimer);
|
|
542
|
+
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
543
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
544
|
+
console.log(`dev-session: superseded — ${fix}`);
|
|
545
|
+
setTimeout(() => process.exit(0), 50);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function heartbeat(originUrl, { claim = false } = {}) {
|
|
532
549
|
await refreshSchemaIfChanged();
|
|
533
550
|
try {
|
|
534
551
|
const r = await fetch(`${host}/api/dev-session`, {
|
|
@@ -537,16 +554,23 @@ async function dev() {
|
|
|
537
554
|
body: JSON.stringify({
|
|
538
555
|
slug: meta.slug,
|
|
539
556
|
tunnelUrl: originUrl,
|
|
557
|
+
sessionId,
|
|
558
|
+
claim,
|
|
540
559
|
name: currentMeta.name,
|
|
541
560
|
icon: currentMeta.icon,
|
|
542
561
|
buildId,
|
|
543
562
|
schemaJson: currentMeta.schemaJson,
|
|
544
563
|
}),
|
|
564
|
+
signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
|
|
545
565
|
});
|
|
546
566
|
const data = await r.json().catch(() => null);
|
|
547
567
|
if (!r.ok) {
|
|
568
|
+
if (data?.code === "DEV_SESSION_SUPERSEDED") {
|
|
569
|
+
stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
548
572
|
console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
|
|
549
|
-
return;
|
|
573
|
+
return false;
|
|
550
574
|
}
|
|
551
575
|
if (data?.publishRequested && !publishing) {
|
|
552
576
|
publishing = true;
|
|
@@ -567,7 +591,11 @@ async function dev() {
|
|
|
567
591
|
});
|
|
568
592
|
publishing = false;
|
|
569
593
|
}
|
|
570
|
-
|
|
594
|
+
return true;
|
|
595
|
+
} catch {
|
|
596
|
+
console.log("dev-session: heartbeat failed — retrying on the next beat");
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
571
599
|
}
|
|
572
600
|
|
|
573
601
|
async function startDevSession() {
|
|
@@ -575,27 +603,60 @@ async function dev() {
|
|
|
575
603
|
console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
|
|
576
604
|
return;
|
|
577
605
|
}
|
|
606
|
+
await clearDevSession();
|
|
578
607
|
let originUrl = `http://localhost:${port}`;
|
|
608
|
+
let tunnelUpdate = Promise.resolve();
|
|
609
|
+
let tunnelVersion = 0;
|
|
610
|
+
async function activateTunnelUrl(url, { initial = false } = {}) {
|
|
611
|
+
const version = ++tunnelVersion;
|
|
612
|
+
if (!initial) {
|
|
613
|
+
console.log(`tunnel: changed to ${url}`);
|
|
614
|
+
await clearDevSession();
|
|
615
|
+
}
|
|
616
|
+
console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
|
|
617
|
+
const dnsLive = await waitForDns(url.replace("https://", ""));
|
|
618
|
+
if (ended || version !== tunnelVersion) return "superseded";
|
|
619
|
+
if (!dnsLive) {
|
|
620
|
+
if (initial) {
|
|
621
|
+
console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
|
|
622
|
+
} else {
|
|
623
|
+
console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
|
|
624
|
+
}
|
|
625
|
+
return "failed";
|
|
626
|
+
}
|
|
627
|
+
originUrl = url;
|
|
628
|
+
console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
|
|
629
|
+
if (!initial && !(await heartbeat(originUrl))) {
|
|
630
|
+
console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
|
|
631
|
+
}
|
|
632
|
+
return "activated";
|
|
633
|
+
}
|
|
634
|
+
const registerTunnelUrl = (url) => {
|
|
635
|
+
tunnelUpdate = tunnelUpdate.then(async () => {
|
|
636
|
+
if (ended || url === originUrl) return;
|
|
637
|
+
await activateTunnelUrl(url);
|
|
638
|
+
}).catch(() => {
|
|
639
|
+
console.log("tunnel: URL update failed — waiting for the next tunnel URL");
|
|
640
|
+
});
|
|
641
|
+
};
|
|
579
642
|
if (!rest.includes("--no-tunnel")) {
|
|
580
643
|
console.log("tunnel: starting (cloudflared quick tunnel)…");
|
|
581
|
-
const t = await startTunnel(port);
|
|
644
|
+
const t = await startTunnel(port, registerTunnelUrl);
|
|
582
645
|
tunnelChild = t.child;
|
|
583
646
|
if (t.url) {
|
|
584
647
|
console.log(`tunnel: ${t.url}`);
|
|
585
|
-
|
|
586
|
-
if (await waitForDns(t.url.replace("https://", ""))) {
|
|
587
|
-
originUrl = t.url;
|
|
588
|
-
console.log("tunnel: DNS live");
|
|
589
|
-
} else {
|
|
590
|
-
console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
|
|
591
|
-
}
|
|
648
|
+
await activateTunnelUrl(t.url, { initial: true });
|
|
592
649
|
} else {
|
|
593
650
|
console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
|
|
594
651
|
}
|
|
595
652
|
}
|
|
596
|
-
await heartbeat(originUrl);
|
|
597
|
-
console.log(
|
|
598
|
-
|
|
653
|
+
const registered = await heartbeat(originUrl, { claim: true });
|
|
654
|
+
console.log(
|
|
655
|
+
registered
|
|
656
|
+
? `studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`
|
|
657
|
+
: `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
|
|
658
|
+
);
|
|
659
|
+
hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
|
|
599
660
|
}
|
|
600
661
|
|
|
601
662
|
let announced = false;
|
|
@@ -629,6 +690,7 @@ async function resolvesVia(dohBase, hostname) {
|
|
|
629
690
|
try {
|
|
630
691
|
const r = await fetch(`${dohBase}?name=${hostname}&type=A`, {
|
|
631
692
|
headers: { accept: "application/dns-json" },
|
|
693
|
+
signal: AbortSignal.timeout(3000),
|
|
632
694
|
});
|
|
633
695
|
const d = await r.json();
|
|
634
696
|
return Array.isArray(d.Answer) && d.Answer.some((a) => a.type === 1);
|
|
@@ -658,7 +720,7 @@ async function waitForDns(hostname) {
|
|
|
658
720
|
// Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
|
|
659
721
|
// binary on first use). Resolves with the public URL, or null on failure —
|
|
660
722
|
// dev mode then falls back to localhost-only registration.
|
|
661
|
-
function startTunnel(port) {
|
|
723
|
+
function startTunnel(port, onUrlChange) {
|
|
662
724
|
return new Promise((resolve) => {
|
|
663
725
|
let child;
|
|
664
726
|
try {
|
|
@@ -675,12 +737,19 @@ function startTunnel(port) {
|
|
|
675
737
|
resolve({ child, url: null });
|
|
676
738
|
}
|
|
677
739
|
}, 45_000);
|
|
740
|
+
let currentUrl = null;
|
|
678
741
|
const scan = (chunk) => {
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
742
|
+
const urls = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
|
|
743
|
+
for (const url of urls) {
|
|
744
|
+
if (url === currentUrl) continue;
|
|
745
|
+
currentUrl = url;
|
|
746
|
+
if (!settled) {
|
|
747
|
+
settled = true;
|
|
748
|
+
clearTimeout(timer);
|
|
749
|
+
resolve({ child, url });
|
|
750
|
+
} else {
|
|
751
|
+
onUrlChange?.(url);
|
|
752
|
+
}
|
|
684
753
|
}
|
|
685
754
|
};
|
|
686
755
|
child.stdout.on("data", scan);
|