@montytools/cli 0.1.6 → 0.2.1
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 +186 -16
- package/package.json +1 -1
- package/skills/monty-build/SKILL.md +7 -3
- package/template/AGENTS.md +9 -3
- package/template/package.json +1 -1
package/bin/monty.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
8
8
|
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
|
+
import { connect as netConnect } from "node:net";
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
import { basename, dirname, join, relative } from "node:path";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -396,26 +397,40 @@ function typecheckApp() {
|
|
|
396
397
|
}
|
|
397
398
|
|
|
398
399
|
|
|
400
|
+
// Probe by CONNECTING (not binding): SO_REUSEADDR lets a wildcard bind
|
|
401
|
+
// "succeed" on macOS even when a specific loopback address holds the port,
|
|
402
|
+
// so bind-probes lie. A successful connect = someone is listening.
|
|
403
|
+
function portTaken(host, port) {
|
|
404
|
+
return new Promise((resolve) => {
|
|
405
|
+
const sock = netConnect({ host, port });
|
|
406
|
+
const done = (taken) => {
|
|
407
|
+
sock.destroy();
|
|
408
|
+
resolve(taken);
|
|
409
|
+
};
|
|
410
|
+
sock.once("connect", () => done(true));
|
|
411
|
+
sock.once("error", () => done(false));
|
|
412
|
+
sock.setTimeout(400, () => done(false));
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
399
416
|
async function freePort(start) {
|
|
400
417
|
for (let p = start; p < start + 50; p++) {
|
|
401
|
-
|
|
402
|
-
const probe = createServer();
|
|
403
|
-
probe.once("error", () => resolve(false));
|
|
404
|
-
probe.listen(p, "127.0.0.1", () => probe.close(() => resolve(true)));
|
|
405
|
-
});
|
|
406
|
-
if (ok) return p;
|
|
418
|
+
if (!(await portTaken("127.0.0.1", p)) && !(await portTaken("::1", p))) return p;
|
|
407
419
|
}
|
|
408
420
|
fail("NO_FREE_PORT", `No free port between ${start} and ${start + 49}. Pass --port <n>.`);
|
|
409
421
|
}
|
|
410
422
|
|
|
411
423
|
// ── monty dev ──────────────────────────────────────────────────────────────
|
|
424
|
+
// Development mode: vite locally + a Cloudflare quick tunnel registered as
|
|
425
|
+
// the app's DEV channel, so workspace admins see the app live (HMR included)
|
|
426
|
+
// at usemonty.dev while it runs. Data is #dev-sandboxed automatically (vite
|
|
427
|
+
// dev build). The heartbeat doubles as the publish poll: when an owner
|
|
428
|
+
// clicks Publish in the workspace, this process builds + uploads for real.
|
|
412
429
|
async function dev() {
|
|
413
|
-
const appDir =
|
|
414
|
-
if (!existsSync(join(appDir, "monty.config.ts"))) {
|
|
415
|
-
fail("NOT_A_MONTY_APP", "No monty.config.ts here. Run `monty dev` from your app's root directory.");
|
|
416
|
-
}
|
|
430
|
+
const appDir = requireAppDir("dev");
|
|
417
431
|
const meta = await compileConfig(appDir);
|
|
418
|
-
const
|
|
432
|
+
const cfg = loadConfig();
|
|
433
|
+
const host = cfg?.host ?? DEFAULT_HOST;
|
|
419
434
|
// Auto-pick a free port (agents run several apps side by side); an
|
|
420
435
|
// explicit --port is honored strictly.
|
|
421
436
|
const requested = flag("port");
|
|
@@ -426,18 +441,173 @@ async function dev() {
|
|
|
426
441
|
cwd: appDir,
|
|
427
442
|
stdio: ["ignore", "pipe", "inherit"],
|
|
428
443
|
});
|
|
444
|
+
|
|
445
|
+
let tunnelChild = null;
|
|
446
|
+
let hbTimer = null;
|
|
447
|
+
let publishing = false;
|
|
448
|
+
let ended = false;
|
|
449
|
+
const buildFile = join(appDir, ".monty", "build");
|
|
450
|
+
const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
|
|
451
|
+
|
|
452
|
+
async function endSession() {
|
|
453
|
+
if (ended) return;
|
|
454
|
+
ended = true;
|
|
455
|
+
if (hbTimer) clearInterval(hbTimer);
|
|
456
|
+
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
457
|
+
if (cfg?.key) {
|
|
458
|
+
try {
|
|
459
|
+
await fetch(`${host}/api/dev-session`, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
|
|
462
|
+
body: JSON.stringify({ slug: meta.slug, end: true }),
|
|
463
|
+
signal: AbortSignal.timeout(2000),
|
|
464
|
+
});
|
|
465
|
+
} catch { /* best effort */ }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function heartbeat(originUrl) {
|
|
470
|
+
try {
|
|
471
|
+
const r = await fetch(`${host}/api/dev-session`, {
|
|
472
|
+
method: "POST",
|
|
473
|
+
headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
|
|
474
|
+
body: JSON.stringify({ slug: meta.slug, tunnelUrl: originUrl, name: meta.name, icon: meta.icon, buildId }),
|
|
475
|
+
});
|
|
476
|
+
const data = await r.json().catch(() => null);
|
|
477
|
+
if (!r.ok) {
|
|
478
|
+
console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (data?.publishRequested && !publishing) {
|
|
482
|
+
publishing = true;
|
|
483
|
+
console.log("publish: requested from the workspace — building & uploading…");
|
|
484
|
+
await new Promise((resolve) => {
|
|
485
|
+
const pub = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
|
|
486
|
+
cwd: appDir,
|
|
487
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
488
|
+
});
|
|
489
|
+
pub.on("exit", (code) => {
|
|
490
|
+
console.log(
|
|
491
|
+
code === 0
|
|
492
|
+
? "publish: done — the workspace now serves the new version (dev session continues)"
|
|
493
|
+
: "publish: FAILED — fix the errors above, then click Publish again",
|
|
494
|
+
);
|
|
495
|
+
resolve(undefined);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
publishing = false;
|
|
499
|
+
}
|
|
500
|
+
} catch { /* transient network hiccup — next beat retries */ }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function startDevSession() {
|
|
504
|
+
if (!cfg?.key) {
|
|
505
|
+
console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
let originUrl = `http://localhost:${port}`;
|
|
509
|
+
if (!rest.includes("--no-tunnel")) {
|
|
510
|
+
console.log("tunnel: starting (cloudflared quick tunnel)…");
|
|
511
|
+
const t = await startTunnel(port);
|
|
512
|
+
tunnelChild = t.child;
|
|
513
|
+
if (t.url) {
|
|
514
|
+
console.log(`tunnel: ${t.url}`);
|
|
515
|
+
console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
|
|
516
|
+
if (await waitForDns(t.url.replace("https://", ""))) {
|
|
517
|
+
originUrl = t.url;
|
|
518
|
+
console.log("tunnel: DNS live");
|
|
519
|
+
} else {
|
|
520
|
+
console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
await heartbeat(originUrl);
|
|
527
|
+
console.log(`workspace: ${host}/apps/${meta.slug} — DEV mode for admins while this runs; click Publish there to ship`);
|
|
528
|
+
hbTimer = setInterval(() => void heartbeat(originUrl), 30_000);
|
|
529
|
+
}
|
|
530
|
+
|
|
429
531
|
let announced = false;
|
|
430
532
|
child.stdout.on("data", (chunk) => {
|
|
431
533
|
const text = chunk.toString();
|
|
432
534
|
process.stdout.write(text);
|
|
433
535
|
if (!announced && /localhost:\d+/.test(text)) {
|
|
434
536
|
announced = true;
|
|
435
|
-
console.log(`open: ${host}/apps/${meta.slug}?dev=http://localhost:${port}`);
|
|
436
537
|
console.log(`data: sandboxed to "${meta.slug}#dev" (live records untouched)`);
|
|
437
538
|
console.log(`ready: http://localhost:${port}`);
|
|
539
|
+
void startDevSession();
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
child.on("exit", (code) => {
|
|
543
|
+
void endSession().then(() => process.exit(code ?? 0));
|
|
544
|
+
});
|
|
545
|
+
process.on("SIGINT", () => {
|
|
546
|
+
void endSession().then(() => process.exit(130));
|
|
547
|
+
});
|
|
548
|
+
process.on("SIGTERM", () => {
|
|
549
|
+
void endSession().then(() => process.exit(143));
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
// trycloudflare DNS takes up to a couple of minutes to propagate. Registering
|
|
555
|
+
// the origin before it resolves would make admins' browsers cache NXDOMAIN
|
|
556
|
+
// (macOS negative cache ≈ 30 min of a broken iframe) — so gate on DNS via
|
|
557
|
+
// DoH, which never touches the local resolver.
|
|
558
|
+
async function waitForDns(hostname) {
|
|
559
|
+
for (let i = 0; i < 36; i++) {
|
|
560
|
+
try {
|
|
561
|
+
const r = await fetch(
|
|
562
|
+
`https://cloudflare-dns.com/dns-query?name=${hostname}&type=A`,
|
|
563
|
+
{ headers: { accept: "application/dns-json" } },
|
|
564
|
+
);
|
|
565
|
+
const d = await r.json();
|
|
566
|
+
if (Array.isArray(d.Answer) && d.Answer.some((a) => a.type === 1)) return true;
|
|
567
|
+
} catch { /* transient — retry */ }
|
|
568
|
+
await new Promise((res) => setTimeout(res, 5000));
|
|
569
|
+
}
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
|
|
574
|
+
// binary on first use). Resolves with the public URL, or null on failure —
|
|
575
|
+
// dev mode then falls back to localhost-only registration.
|
|
576
|
+
function startTunnel(port) {
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
let child;
|
|
579
|
+
try {
|
|
580
|
+
child = spawn("npx", ["-y", "cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
|
581
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
582
|
+
});
|
|
583
|
+
} catch {
|
|
584
|
+
return resolve({ child: null, url: null });
|
|
438
585
|
}
|
|
586
|
+
let settled = false;
|
|
587
|
+
const timer = setTimeout(() => {
|
|
588
|
+
if (!settled) {
|
|
589
|
+
settled = true;
|
|
590
|
+
resolve({ child, url: null });
|
|
591
|
+
}
|
|
592
|
+
}, 45_000);
|
|
593
|
+
const scan = (chunk) => {
|
|
594
|
+
const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(String(chunk));
|
|
595
|
+
if (m && !settled) {
|
|
596
|
+
settled = true;
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
resolve({ child, url: m[0] });
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
child.stdout.on("data", scan);
|
|
602
|
+
child.stderr.on("data", scan);
|
|
603
|
+
child.on("exit", () => {
|
|
604
|
+
if (!settled) {
|
|
605
|
+
settled = true;
|
|
606
|
+
clearTimeout(timer);
|
|
607
|
+
resolve({ child: null, url: null });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
439
610
|
});
|
|
440
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
441
611
|
}
|
|
442
612
|
|
|
443
613
|
// ── monty add / components / docs ──────────────────────────────────────────
|
|
@@ -540,9 +710,9 @@ async function docs() {
|
|
|
540
710
|
|
|
541
711
|
// ── monty deploy ───────────────────────────────────────────────────────────
|
|
542
712
|
async function deploy() {
|
|
543
|
-
const appDir =
|
|
544
|
-
if (!
|
|
545
|
-
|
|
713
|
+
const appDir = requireAppDir("deploy");
|
|
714
|
+
if (!rest.includes("--from-dev")) {
|
|
715
|
+
console.log("note: direct deploy skips workspace review — the usual flow is `monty dev` + the Publish button in the workspace.");
|
|
546
716
|
}
|
|
547
717
|
const config = loadConfig();
|
|
548
718
|
if (!config?.key) {
|
package/package.json
CHANGED
|
@@ -19,9 +19,13 @@ the territory.
|
|
|
19
19
|
2. **The loop:** `monty create <slug> --name "Name" --icon <lucide-icon>` →
|
|
20
20
|
(if the prompt includes a `build id`, pass it: `--build <id>` — the
|
|
21
21
|
workspace's New app screen tracks your progress live) →
|
|
22
|
-
`monty install` → edit `monty.config.ts` (zod tables) + `src/routes/` →
|
|
23
|
-
`monty dev` (
|
|
24
|
-
|
|
22
|
+
`monty install` → edit `monty.config.ts` (zod tables) + `src/routes/` →
|
|
23
|
+
verify with `monty dev` (auto-port, already authenticated, sandboxed
|
|
24
|
+
data). **You are done when `monty dev` prints the workspace dev URL and
|
|
25
|
+
the app works — leave `monty dev` running.** Publishing to the whole
|
|
26
|
+
workspace is the OWNER'S click (Publish in the workspace menu bar);
|
|
27
|
+
**never run `monty deploy` yourself** unless the user explicitly asks
|
|
28
|
+
for a direct production deploy.
|
|
25
29
|
3. **Everything through the CLI.** `monty install`, `monty build`,
|
|
26
30
|
`monty typecheck`, `monty dev`, `monty deploy` — never run vite, tsc,
|
|
27
31
|
pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
|
package/template/AGENTS.md
CHANGED
|
@@ -111,9 +111,15 @@ monty dev # Vite + HMR, auto-picks a free port and prints it
|
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
Headless? Verify with `monty build` then `monty typecheck` (typecheck builds
|
|
114
|
-
first when needed — the build generates `src/routeTree.gen.ts`).
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
first when needed — the build generates `src/routeTree.gen.ts`).
|
|
115
|
+
|
|
116
|
+
**Development vs production:** while `monty dev` runs, the app is live in
|
|
117
|
+
the workspace in DEV mode (workspace admins only, tunneled, `#dev` sandboxed
|
|
118
|
+
data). Shipping to the whole team is the owner's **Publish** click in the
|
|
119
|
+
workspace menu bar — it signals your running `monty dev`, which builds,
|
|
120
|
+
typechecks, and uploads. You are done when the app works in dev mode;
|
|
121
|
+
leave `monty dev` running and let the owner publish. Only run
|
|
122
|
+
`monty deploy` directly if the user explicitly asks.
|
|
117
123
|
|
|
118
124
|
**Driving your app in a browser (agents):** while `monty dev` runs, opening
|
|
119
125
|
`http://localhost:5173` is ALREADY AUTHENTICATED — no sign-in screen (the dev
|
package/template/package.json
CHANGED