@montytools/cli 0.1.6 → 0.2.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/bin/monty.mjs CHANGED
@@ -409,13 +409,16 @@ async function freePort(start) {
409
409
  }
410
410
 
411
411
  // ── monty dev ──────────────────────────────────────────────────────────────
412
+ // Development mode: vite locally + a Cloudflare quick tunnel registered as
413
+ // the app's DEV channel, so workspace admins see the app live (HMR included)
414
+ // at usemonty.dev while it runs. Data is #dev-sandboxed automatically (vite
415
+ // dev build). The heartbeat doubles as the publish poll: when an owner
416
+ // clicks Publish in the workspace, this process builds + uploads for real.
412
417
  async function dev() {
413
- const appDir = process.cwd();
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
- }
418
+ const appDir = requireAppDir("dev");
417
419
  const meta = await compileConfig(appDir);
418
- const host = loadConfig()?.host ?? DEFAULT_HOST;
420
+ const cfg = loadConfig();
421
+ const host = cfg?.host ?? DEFAULT_HOST;
419
422
  // Auto-pick a free port (agents run several apps side by side); an
420
423
  // explicit --port is honored strictly.
421
424
  const requested = flag("port");
@@ -426,18 +429,147 @@ async function dev() {
426
429
  cwd: appDir,
427
430
  stdio: ["ignore", "pipe", "inherit"],
428
431
  });
432
+
433
+ let tunnelChild = null;
434
+ let hbTimer = null;
435
+ let publishing = false;
436
+ let ended = false;
437
+ const buildFile = join(appDir, ".monty", "build");
438
+ const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
439
+
440
+ async function endSession() {
441
+ if (ended) return;
442
+ ended = true;
443
+ if (hbTimer) clearInterval(hbTimer);
444
+ try { tunnelChild?.kill(); } catch { /* already gone */ }
445
+ if (cfg?.key) {
446
+ try {
447
+ await fetch(`${host}/api/dev-session`, {
448
+ method: "POST",
449
+ headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
450
+ body: JSON.stringify({ slug: meta.slug, end: true }),
451
+ signal: AbortSignal.timeout(2000),
452
+ });
453
+ } catch { /* best effort */ }
454
+ }
455
+ }
456
+
457
+ async function heartbeat(originUrl) {
458
+ try {
459
+ const r = 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, tunnelUrl: originUrl, name: meta.name, icon: meta.icon, buildId }),
463
+ });
464
+ const data = await r.json().catch(() => null);
465
+ if (!r.ok) {
466
+ console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
467
+ return;
468
+ }
469
+ if (data?.publishRequested && !publishing) {
470
+ publishing = true;
471
+ console.log("publish: requested from the workspace — building & uploading…");
472
+ await new Promise((resolve) => {
473
+ const pub = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
474
+ cwd: appDir,
475
+ stdio: ["ignore", "inherit", "inherit"],
476
+ });
477
+ pub.on("exit", (code) => {
478
+ console.log(
479
+ code === 0
480
+ ? "publish: done — the workspace now serves the new version (dev session continues)"
481
+ : "publish: FAILED — fix the errors above, then click Publish again",
482
+ );
483
+ resolve(undefined);
484
+ });
485
+ });
486
+ publishing = false;
487
+ }
488
+ } catch { /* transient network hiccup — next beat retries */ }
489
+ }
490
+
491
+ async function startDevSession() {
492
+ if (!cfg?.key) {
493
+ console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
494
+ return;
495
+ }
496
+ let originUrl = `http://localhost:${port}`;
497
+ if (!rest.includes("--no-tunnel")) {
498
+ console.log("tunnel: starting (cloudflared quick tunnel)…");
499
+ const t = await startTunnel(port);
500
+ tunnelChild = t.child;
501
+ if (t.url) {
502
+ originUrl = t.url;
503
+ console.log(`tunnel: ${t.url}`);
504
+ } else {
505
+ console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
506
+ }
507
+ }
508
+ await heartbeat(originUrl);
509
+ console.log(`workspace: ${host}/apps/${meta.slug} — DEV mode for admins while this runs; click Publish there to ship`);
510
+ hbTimer = setInterval(() => void heartbeat(originUrl), 30_000);
511
+ }
512
+
429
513
  let announced = false;
430
514
  child.stdout.on("data", (chunk) => {
431
515
  const text = chunk.toString();
432
516
  process.stdout.write(text);
433
517
  if (!announced && /localhost:\d+/.test(text)) {
434
518
  announced = true;
435
- console.log(`open: ${host}/apps/${meta.slug}?dev=http://localhost:${port}`);
436
519
  console.log(`data: sandboxed to "${meta.slug}#dev" (live records untouched)`);
437
520
  console.log(`ready: http://localhost:${port}`);
521
+ void startDevSession();
522
+ }
523
+ });
524
+ child.on("exit", (code) => {
525
+ void endSession().then(() => process.exit(code ?? 0));
526
+ });
527
+ process.on("SIGINT", () => {
528
+ void endSession().then(() => process.exit(130));
529
+ });
530
+ process.on("SIGTERM", () => {
531
+ void endSession().then(() => process.exit(143));
532
+ });
533
+ }
534
+
535
+ // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
536
+ // binary on first use). Resolves with the public URL, or null on failure —
537
+ // dev mode then falls back to localhost-only registration.
538
+ function startTunnel(port) {
539
+ return new Promise((resolve) => {
540
+ let child;
541
+ try {
542
+ child = spawn("npx", ["-y", "cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
543
+ stdio: ["ignore", "pipe", "pipe"],
544
+ });
545
+ } catch {
546
+ return resolve({ child: null, url: null });
438
547
  }
548
+ let settled = false;
549
+ const timer = setTimeout(() => {
550
+ if (!settled) {
551
+ settled = true;
552
+ resolve({ child, url: null });
553
+ }
554
+ }, 45_000);
555
+ const scan = (chunk) => {
556
+ const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(String(chunk));
557
+ if (m && !settled) {
558
+ settled = true;
559
+ clearTimeout(timer);
560
+ resolve({ child, url: m[0] });
561
+ }
562
+ };
563
+ child.stdout.on("data", scan);
564
+ child.stderr.on("data", scan);
565
+ child.on("exit", () => {
566
+ if (!settled) {
567
+ settled = true;
568
+ clearTimeout(timer);
569
+ resolve({ child: null, url: null });
570
+ }
571
+ });
439
572
  });
440
- child.on("exit", (code) => process.exit(code ?? 0));
441
573
  }
442
574
 
443
575
  // ── monty add / components / docs ──────────────────────────────────────────
@@ -540,9 +672,9 @@ async function docs() {
540
672
 
541
673
  // ── monty deploy ───────────────────────────────────────────────────────────
542
674
  async function deploy() {
543
- const appDir = process.cwd();
544
- if (!existsSync(join(appDir, "monty.config.ts"))) {
545
- fail("NOT_A_MONTY_APP", "No monty.config.ts here. Run `monty deploy` from your app's root directory.");
675
+ const appDir = requireAppDir("deploy");
676
+ if (!rest.includes("--from-dev")) {
677
+ console.log("note: direct deploy skips workspace review — the usual flow is `monty dev` + the Publish button in the workspace.");
546
678
  }
547
679
  const config = loadConfig();
548
680
  if (!config?.key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "monty": "./bin/monty.mjs"
@@ -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/` → verify with
23
- `monty dev` (localhost:5173, already authenticated, sandboxed data) →
24
- `monty deploy`. You are done when deploy prints the URL.
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
@@ -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`). `monty
115
- deploy` runs both itself (it never uploads code that doesn't compile), so
116
- deploy is self-verifying.
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