@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.
- package/bin/monty.mjs +116 -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");
|
|
@@ -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
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
569
|
-
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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;
|