@montytools/cli 0.1.5 → 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
@@ -362,36 +362,214 @@ async function create() {
362
362
 
363
363
  installSkills({ appDir: target });
364
364
  console.log(`created: ${target}`);
365
- console.log(`next: cd ${target} && pnpm install && monty dev`);
365
+ console.log(`next: cd ${target} && monty install && monty dev`);
366
+ }
367
+
368
+
369
+ // ── monty install / build / typecheck ───────────────────────────────────────
370
+ // The full app lifecycle goes through the CLI — agents never invoke pnpm,
371
+ // vite, or tsc directly. Same underlying tools, agent-shaped output, and the
372
+ // build-before-typecheck ordering handled for you.
373
+ function installDeps() {
374
+ const appDir = requireAppDir("install");
375
+ const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
376
+ run(appDir, "install", [pm, "install"],
377
+ "Dependency install failed. Read the package manager error above; usually network or a bad package.json edit.");
378
+ }
379
+
380
+ function buildApp() {
381
+ const appDir = requireAppDir("build");
382
+ run(appDir, "build", ["npx", "vite", "build"],
383
+ "The production build failed. Read the vite error above; it names the file to fix.");
384
+ }
385
+
386
+ function typecheckApp() {
387
+ const appDir = requireAppDir("typecheck");
388
+ // routeTree.gen.ts is generated by the build — without it tsc fails on a
389
+ // fresh checkout, so build first when it's missing.
390
+ if (!existsSync(join(appDir, "src", "routeTree.gen.ts"))) {
391
+ run(appDir, "build", ["npx", "vite", "build"],
392
+ "The production build failed. Read the vite error above; it names the file to fix.");
393
+ }
394
+ run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
395
+ "TypeScript errors above. Fix them in the listed files.");
396
+ }
397
+
398
+
399
+ async function freePort(start) {
400
+ for (let p = start; p < start + 50; p++) {
401
+ const ok = await new Promise((resolve) => {
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;
407
+ }
408
+ fail("NO_FREE_PORT", `No free port between ${start} and ${start + 49}. Pass --port <n>.`);
366
409
  }
367
410
 
368
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.
369
417
  async function dev() {
370
- const appDir = process.cwd();
371
- if (!existsSync(join(appDir, "monty.config.ts"))) {
372
- fail("NOT_A_MONTY_APP", "No monty.config.ts here. Run `monty dev` from your app's root directory.");
373
- }
418
+ const appDir = requireAppDir("dev");
374
419
  const meta = await compileConfig(appDir);
375
- const host = loadConfig()?.host ?? DEFAULT_HOST;
376
- const port = Number(flag("port") ?? 5173);
420
+ const cfg = loadConfig();
421
+ const host = cfg?.host ?? DEFAULT_HOST;
422
+ // Auto-pick a free port (agents run several apps side by side); an
423
+ // explicit --port is honored strictly.
424
+ const requested = flag("port");
425
+ const port = requested ? Number(requested) : await freePort(5173);
377
426
 
378
427
  console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
379
428
  const child = spawn("npx", ["vite", "dev", "--port", String(port), "--strictPort"], {
380
429
  cwd: appDir,
381
430
  stdio: ["ignore", "pipe", "inherit"],
382
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
+
383
513
  let announced = false;
384
514
  child.stdout.on("data", (chunk) => {
385
515
  const text = chunk.toString();
386
516
  process.stdout.write(text);
387
517
  if (!announced && /localhost:\d+/.test(text)) {
388
518
  announced = true;
389
- console.log(`open: ${host}/apps/${meta.slug}?dev=http://localhost:${port}`);
390
519
  console.log(`data: sandboxed to "${meta.slug}#dev" (live records untouched)`);
391
520
  console.log(`ready: http://localhost:${port}`);
521
+ void startDevSession();
392
522
  }
393
523
  });
394
- child.on("exit", (code) => process.exit(code ?? 0));
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 });
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
+ });
572
+ });
395
573
  }
396
574
 
397
575
  // ── monty add / components / docs ──────────────────────────────────────────
@@ -400,9 +578,9 @@ async function dev() {
400
578
  // implementation. Only catalog registries and core shadcn resolve.
401
579
 
402
580
  function requireAppDir(cmd) {
403
- const appDir = process.cwd();
404
- if (!existsSync(join(appDir, "monty.config.ts"))) {
405
- fail("NOT_A_MONTY_APP", `No monty.config.ts here. Run \`monty ${cmd}\` from your app's root directory.`);
581
+ const appDir = findAppRoot(process.cwd());
582
+ if (!appDir) {
583
+ fail("NOT_A_MONTY_APP", `Not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one — then run \`monty ${cmd}\`.`);
406
584
  }
407
585
  return appDir;
408
586
  }
@@ -494,9 +672,9 @@ async function docs() {
494
672
 
495
673
  // ── monty deploy ───────────────────────────────────────────────────────────
496
674
  async function deploy() {
497
- const appDir = process.cwd();
498
- if (!existsSync(join(appDir, "monty.config.ts"))) {
499
- 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.");
500
678
  }
501
679
  const config = loadConfig();
502
680
  if (!config?.key) {
@@ -642,20 +820,32 @@ switch (command) {
642
820
  installSkills({ appDir: findAppRoot(process.cwd()), silent: false });
643
821
  console.log("skills: up to date");
644
822
  break;
823
+ case "install":
824
+ installDeps();
825
+ break;
826
+ case "build":
827
+ buildApp();
828
+ break;
829
+ case "typecheck":
830
+ typecheckApp();
831
+ break;
645
832
  case "deploy":
646
833
  await deploy();
647
834
  break;
648
835
  default:
649
- console.log("usage: monty <login|create|current|select|apps|dev|add|components|docs|deploy|skills>");
836
+ console.log("usage: monty <login|create|current|select|apps|install|dev|build|typecheck|add|components|docs|deploy|skills>");
650
837
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
651
838
  console.log(" create <slug> [--name N] [--icon I] [--build ID] stamp a new app into ~/Monty/<slug>");
652
- console.log(" dev [--port 5173] run the app locally (sandboxed data)");
839
+ console.log(" dev [--port N] run locally, auto-picks a free port (sandboxed data)");
653
840
  console.log(" add <name...> install curated UI components (see `monty components`)");
654
841
  console.log(" components [query] list the curated component catalog");
655
842
  console.log(" docs <name> view a component's source before installing");
656
843
  console.log(" current which app folder am I in?");
657
844
  console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
658
845
  console.log(" apps list local apps in ~/Monty");
846
+ console.log(" install install app dependencies");
847
+ console.log(" build production build (vite, via monty)");
848
+ console.log(" typecheck typecheck (builds first if needed)");
659
849
  console.log(" deploy build + upload this app");
660
850
  console.log(" skills install/refresh the agent build skill");
661
851
  process.exit(command ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "monty": "./bin/monty.mjs"
@@ -19,21 +19,29 @@ 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
- 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.
25
- 3. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
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.
29
+ 3. **Everything through the CLI.** `monty install`, `monty build`,
30
+ `monty typecheck`, `monty dev`, `monty deploy` — never run vite, tsc,
31
+ pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
32
+ prints it; `monty typecheck` builds first when needed.
33
+ 4. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
26
34
  `@montytools/sdk/react` (hooks: `useList`, `useInsert`, …). Never import
27
35
  Clerk or Convex directly; never fetch external APIs from app code — the
28
36
  platform CSP blocks them.
29
- 4. **Schema is zod in `monty.config.ts`.** Field names `_*`, `updatedAt`,
37
+ 5. **Schema is zod in `monty.config.ts`.** Field names `_*`, `updatedAt`,
30
38
  `createdBy` are reserved. Push happens automatically on dev/deploy.
31
- 5. **UI is stock shadcn** (preset already wired). Add curated components with
39
+ 6. **UI is stock shadcn** (preset already wired). Add curated components with
32
40
  `monty add <name>`; browse with `monty components` / `monty docs <name>`.
33
- 6. **Errors are instructions.** Every failure prints
41
+ 7. **Errors are instructions.** Every failure prints
34
42
  `[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
35
43
  Typecheck failures block deploy by design.
36
- 7. **Verify before deploy.** `monty dev` writes to a `#dev` sandbox — live
44
+ 8. **Verify before deploy.** `monty dev` writes to a `#dev` sandbox — live
37
45
  team records are never touched, so exercise the app for real.
38
46
 
39
47
  ## CLI reference
@@ -43,7 +51,8 @@ the territory.
43
51
  | `monty login` | browser sign-in (loopback authorize), once per machine |
44
52
  | `monty create <slug>` | stamp a new app into `~/Monty/<slug>` |
45
53
  | `monty current` / `select` / `apps` | where am I / jump to app / list local |
46
- | `monty dev` | run locally on :5173, sandboxed data, auto-auth |
54
+ | `monty install` / `build` / `typecheck` | full lifecycle via the CLI no raw pnpm/vite/tsc |
55
+ | `monty dev` | run locally (auto-picks a free port), sandboxed data, auto-auth |
47
56
  | `monty add <name…>` | install curated shadcn components |
48
57
  | `monty deploy` | build + typecheck + upload; app appears in the workspace |
49
58
  | `monty skills` | (re)install this skill for your agent |
@@ -102,14 +102,24 @@ Every platform error is one line shaped like:
102
102
 
103
103
  ## Dev loop
104
104
 
105
+ Everything goes through the `monty` CLI — never run vite, tsc, pnpm, or npm
106
+ scripts directly:
107
+
105
108
  ```
106
- pnpm dev # Vite + HMR on :5173; sign in with your workspace account
109
+ monty install # dependencies
110
+ monty dev # Vite + HMR, auto-picks a free port and prints it
107
111
  ```
108
112
 
109
- Headless? Verify with `pnpm exec vite build` then `pnpm exec tsc --noEmit` in
110
- that order: the first build generates `src/routeTree.gen.ts`, without which
111
- typecheck fails on a fresh app. `monty deploy` runs both itself (it never
112
- uploads code that doesn't compile), so deploy is self-verifying.
113
+ Headless? Verify with `monty build` then `monty typecheck` (typecheck builds
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.
113
123
 
114
124
  **Driving your app in a browser (agents):** while `monty dev` runs, opening
115
125
  `http://localhost:5173` is ALREADY AUTHENTICATED — no sign-in screen (the dev