@montytools/cli 0.4.2 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // monty — the Monty platform CLI. Output is agent-shaped: structured
3
3
  // single-line events, no spinners, instruction-shaped errors, and a
4
- // deterministic final line (`deployed: …` / `error: …`).
4
+ // deterministic final line (`saved: …` / `error: …`).
5
5
 
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { createHash, randomBytes } from "node:crypto";
@@ -16,6 +16,8 @@ import { fileURLToPath } from "node:url";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { CATALOG, REGISTRIES } from "./catalog.mjs";
18
18
  import { CompileError, compileAppConfig } from "../lib/compile.mjs";
19
+ import { manifestHash } from "../lib/schemaCodegen.mjs";
20
+ import { readSchemaState, schemaPull, writeSchemaState } from "../lib/schemaPull.mjs";
19
21
 
20
22
  // MONTY_HOME overrides the state root (default ~/.monty): config.json,
21
23
  // apps/, and desktop.json all live under it. This is how a second, isolated
@@ -61,10 +63,30 @@ function flag(name) {
61
63
  }
62
64
 
63
65
  function fail(code, fix) {
66
+ // A failing save narrates itself to the desktop chip before exiting.
67
+ saveFailNote?.(code, fix);
68
+ saveFailNote = null;
64
69
  console.error(`error: [MontyError ${code}] Fix: ${fix}`);
65
70
  process.exit(1);
66
71
  }
67
72
 
73
+ // Set by deploy() while a save runs; fail() and the exit hook route the
74
+ // error instruction into .monty/save.json so the chip shows it.
75
+ let saveFailNote = null;
76
+
77
+ // The save state file the desktop tails: .monty/save.json narrates every
78
+ // save — agent-run saves in their own terminal included — so the chip can
79
+ // show Saving…/Saved/"Couldn't save" without parsing any output.
80
+ function writeSaveJson(appDir, state) {
81
+ try {
82
+ mkdirSync(join(appDir, ".monty"), { recursive: true });
83
+ writeFileSync(
84
+ join(appDir, ".monty", "save.json"),
85
+ JSON.stringify({ ...state, at: Date.now() }, null, 2) + "\n",
86
+ );
87
+ } catch { /* advisory — the chip just misses this save */ }
88
+ }
89
+
68
90
  // ── Profiles & the .montyrc directory pin ──────────────────────────────────
69
91
  // One key PER HOST (like kubectl contexts): logging into the local platform
70
92
  // host never clobbers the prod key. Which host a command targets resolves,
@@ -255,6 +277,22 @@ function readMarker(path) {
255
277
  }
256
278
  }
257
279
 
280
+ // True when the installed marker is BEHIND this CLI. Ordering matters: two
281
+ // CLI versions share one machine (repo link vs npm global, bundle vs
282
+ // global), and an equality check makes them overwrite each other's skills
283
+ // on every alternating run — the older one must never win.
284
+ function markerOutdated(marker) {
285
+ if (!marker) return true;
286
+ const a = marker.split(".").map(Number);
287
+ const b = CLI_VERSION.split(".").map(Number);
288
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return marker !== CLI_VERSION;
289
+ for (let i = 0; i < 3; i++) {
290
+ if ((a[i] || 0) < (b[i] || 0)) return true;
291
+ if ((a[i] || 0) > (b[i] || 0)) return false;
292
+ }
293
+ return false;
294
+ }
295
+
258
296
  function skillsCliAdd({ global = false, cwd = undefined } = {}) {
259
297
  const args = ["-y", "skills", "add", SKILLS_SRC, "-y", "--copy", ...(global ? ["-g"] : []), ...SKILL_AGENTS];
260
298
  const res = spawnSync("npx", args, { cwd, stdio: "ignore", timeout: 120_000 });
@@ -283,7 +321,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
283
321
  if (process.env.MONTY_NO_SKILLS) return;
284
322
  try {
285
323
  let changed = false;
286
- if (force || readMarker(GLOBAL_SKILLS_MARKER) !== CLI_VERSION) {
324
+ if (force || markerOutdated(readMarker(GLOBAL_SKILLS_MARKER))) {
287
325
  if (!skillsCliAdd({ global: true })) manualInstall(null);
288
326
  mkdirSync(CONFIG_DIR, { recursive: true });
289
327
  writeFileSync(GLOBAL_SKILLS_MARKER, CLI_VERSION + "\n");
@@ -291,7 +329,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
291
329
  }
292
330
  if (appDir) {
293
331
  const marker = join(appDir, ".agents", "skills", "monty-build", "VERSION");
294
- if (force || readMarker(marker) !== CLI_VERSION) {
332
+ if (force || markerOutdated(readMarker(marker))) {
295
333
  if (!skillsCliAdd({ cwd: appDir })) manualInstall(appDir);
296
334
  mkdirSync(dirname(marker), { recursive: true });
297
335
  writeFileSync(marker, CLI_VERSION + "\n");
@@ -308,17 +346,42 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
308
346
  // ── monty current / select / apps ───────────────────────────────────────────
309
347
  // Folder management users never think about: every app lives in ~/Monty,
310
348
  // `current` says where you are, `select` prints the folder for cd $(...).
349
+ // The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
350
+ // create and pull) or, for older folders, monty.config.ts.
351
+ function isAppRoot(dir) {
352
+ return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
353
+ }
354
+
311
355
  function findAppRoot(start) {
312
356
  let d = start;
313
357
  for (;;) {
314
- if (existsSync(join(d, "monty.config.ts"))) return d;
358
+ if (isAppRoot(d)) return d;
315
359
  const parent = dirname(d);
316
360
  if (parent === d) return null;
317
361
  d = parent;
318
362
  }
319
363
  }
320
364
 
365
+ // The identity stamp: { id, slug, name, icon, registryOwned? } — the app's
366
+ // durable identity on this machine, independent of the config file.
367
+ function readAppJson(dir) {
368
+ try {
369
+ return JSON.parse(readFileSync(join(dir, ".monty", "app.json"), "utf8"));
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ function writeAppJson(dir, patch) {
376
+ mkdirSync(join(dir, ".monty"), { recursive: true });
377
+ const next = { ...(readAppJson(dir) ?? {}), ...patch };
378
+ writeFileSync(join(dir, ".monty", "app.json"), JSON.stringify(next, null, 2) + "\n");
379
+ return next;
380
+ }
381
+
321
382
  function readSlugFromConfig(dir) {
383
+ const stamped = readAppJson(dir)?.slug;
384
+ if (typeof stamped === "string" && stamped) return stamped;
322
385
  try {
323
386
  return /slug:\s*"([^"]+)"/.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
324
387
  } catch {
@@ -327,6 +390,8 @@ function readSlugFromConfig(dir) {
327
390
  }
328
391
 
329
392
  function readIdFromConfig(dir) {
393
+ const stamped = readAppJson(dir)?.id;
394
+ if (typeof stamped === "string" && stamped) return stamped;
330
395
  try {
331
396
  return /^[ \t]*id:\s*"([^"]+)"/m.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
332
397
  } catch {
@@ -338,7 +403,7 @@ function scanAppsHome(root) {
338
403
  if (!existsSync(root)) return [];
339
404
  return readdirSync(root)
340
405
  .map((name) => join(root, name))
341
- .filter((p) => existsSync(join(p, "monty.config.ts")))
406
+ .filter((p) => isAppRoot(p))
342
407
  .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
343
408
  }
344
409
 
@@ -389,11 +454,10 @@ function apps() {
389
454
  }
390
455
 
391
456
  // Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
392
- // tar.gz buffer + its sha256 — the snapshot unit `monty commit` and deploys
393
- // both upload. gzip runs with -n (no embedded timestamp) so an UNCHANGED
394
- // tree packs to identical bytes — that's what makes "nothing to commit"
395
- // detectable by hash. Returns null when packing fails; { tooLarge } past
396
- // the 10MB cap.
457
+ // tar.gz buffer + its sha256 — the snapshot unit every `monty save`
458
+ // uploads. gzip runs with -n (no embedded timestamp) so an UNCHANGED tree
459
+ // packs to identical bytes — the hash doubles as the change detector.
460
+ // Returns null when packing fails; { tooLarge } past the 10MB cap.
397
461
  function packSource(appDir) {
398
462
  mkdirSync(join(appDir, ".monty"), { recursive: true });
399
463
  const srcTar = join(appDir, ".monty", "source-upload.tar.gz");
@@ -415,60 +479,12 @@ function packSource(appDir) {
415
479
  }
416
480
 
417
481
  function readSlug(appDir) {
418
- try {
419
- return /slug:\s*"([^"]+)"/.exec(readFileSync(join(appDir, "monty.config.ts"), "utf8"))?.[1] ?? null;
420
- } catch {
421
- return null;
422
- }
423
- }
424
-
425
- // ── monty commit ───────────────────────────────────────────────────────────
426
- // Version the app's source WITHOUT publishing: pack the tree, upload it as
427
- // one line of history. Git commit with everything stripped except "track
428
- // versions" — no branches, no diffs, no local repo; history lives in the
429
- // workspace and survives this folder.
430
- async function commit() {
431
- const appDir = requireAppDir("commit");
432
- const slug = readSlug(appDir);
433
- if (!slug) fail("NO_SLUG", "monty.config.ts has no slug — fix the config, then retry.");
434
- const { host, key } = loadConfig();
435
- if (!key) fail("NOT_LOGGED_IN", "Committing stores the snapshot in your workspace. Run `monty login` first.");
436
- const mIdx = rest.indexOf("-m");
437
- const message = flag("message") ?? (mIdx >= 0 ? rest[mIdx + 1] : undefined) ?? "checkpoint";
438
- const packed = packSource(appDir);
439
- if (packed === null) fail("PACK_FAILED", "Packing the source failed (tar error). Retry; check the folder is readable.");
440
- if (packed.tooLarge) fail("SOURCE_TOO_LARGE", "The source tree exceeds 10 MB (node_modules/dist excluded). Remove large assets, then retry.");
441
- try {
442
- const stamp = JSON.parse(readFileSync(join(appDir, ".monty", "source.json"), "utf8"));
443
- if (stamp.hash === packed.hash) {
444
- console.log(`nothing to commit — source unchanged since ${packed.hash.slice(0, 7)}`);
445
- return;
446
- }
447
- } catch {
448
- /* no stamp yet — first commit from this folder */
449
- }
450
- const form = new FormData();
451
- form.set("monty", JSON.stringify({ slug, hash: packed.hash, message }));
452
- form.set("source", new Blob([packed.buf]), "source.tar.gz");
453
- const res = await fetch(`${host}/api/source`, {
454
- method: "POST",
455
- headers: { authorization: `Bearer ${key}` },
456
- body: form,
457
- });
458
- const body = await res.json().catch(() => null);
459
- if (!res.ok || !body?.ok) {
460
- fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Uploading the snapshot failed — check the connection and retry.");
461
- }
462
- writeFileSync(
463
- join(appDir, ".monty", "source.json"),
464
- JSON.stringify({ hash: packed.hash, syncedAt: Date.now() }) + "\n",
465
- );
466
- console.log(`committed: ${packed.hash.slice(0, 7)} "${message}" (${(packed.buf.byteLength / 1024).toFixed(0)} KB)`);
482
+ return readSlugFromConfig(appDir);
467
483
  }
468
484
 
469
485
  // ── monty log ──────────────────────────────────────────────────────────────
470
- // The app's version history, newest first. Commits and publishes share one
471
- // timeline. (Not `monty logs` — that tails the dev shell.)
486
+ // The app's version history, newest first one row per `monty save`.
487
+ // (Not `monty logs` — that tails the dev shell.)
472
488
  async function versionsLog() {
473
489
  const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
474
490
  if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
@@ -482,19 +498,19 @@ async function versionsLog() {
482
498
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not list versions — check the connection and `monty login`.");
483
499
  }
484
500
  if (body.versions.length === 0) {
485
- console.log(`no versions of "${slug}" yet — \`monty commit\` or a publish creates the first one.`);
501
+ console.log(`no versions of "${slug}" yet — \`monty save\` creates the first one.`);
486
502
  return;
487
503
  }
488
504
  for (const v of body.versions) {
489
505
  const when = new Date(v.createdAt).toISOString().slice(0, 16).replace("T", " ");
490
- console.log(`${v.hash.slice(0, 7)} ${when} ${v.published ? "[published] " : ""}${v.message}`);
506
+ console.log(`${v.hash.slice(0, 7)} ${when} ${v.message}`);
491
507
  }
492
508
  console.log(`\nrestore one: monty pull ${slug} --version <hash> [--force]`);
493
509
  }
494
510
 
495
511
  // ── monty pull ─────────────────────────────────────────────────────────────
496
- // Restore an app's published source snapshot onto this machine. Every
497
- // `monty deploy`/publish uploads the source tree beside the bundle; pull is
512
+ // Restore an app's saved source snapshot onto this machine. Every
513
+ // `monty save` uploads the source tree beside the bundle; pull is
498
514
  // how a second machine (or one that lost the folder) gets the code back.
499
515
  // Refuses to touch an existing folder without --force — it may hold
500
516
  // unpublished work the snapshot would destroy.
@@ -539,11 +555,11 @@ async function pull() {
539
555
  expectedHash = matches[0].hash;
540
556
  downloadUrl = `${host}/api/source?slug=${slug}&hash=${expectedHash}`;
541
557
  } else if (!app.sourceHash) {
542
- fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each publish and \`monty commit\`. Run either once from the machine that has the source, then pull works everywhere.`);
558
+ fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each \`monty save\`. Run it once from the machine that has the source, then pull works everywhere.`);
543
559
  }
544
560
  const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
545
561
  if (existsSync(target) && !rest.includes("--force")) {
546
- fail("DIR_EXISTS", `${target} already exists and may hold unpublished work. Compare it with the published version first; re-run with --force to REPLACE it with the snapshot.`);
562
+ fail("DIR_EXISTS", `${target} already exists and may hold unsaved work. Compare it with the saved version first; re-run with --force to REPLACE it with the snapshot.`);
547
563
  }
548
564
 
549
565
  console.log(`pull: ${slug}${versionFlag ? ` @ ${expectedHash.slice(0, 7)}` : ""} <- ${host}`);
@@ -557,7 +573,7 @@ async function pull() {
557
573
  const buf = Buffer.from(await res.arrayBuffer());
558
574
  const hash = createHash("sha256").update(buf).digest("hex");
559
575
  if (hash !== expectedHash) {
560
- fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, republish the app from a machine that has the source.");
576
+ fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
561
577
  }
562
578
 
563
579
  // Extract into a staging folder, then move into place — a failed extract
@@ -571,7 +587,7 @@ async function pull() {
571
587
  rmSync(tarFile, { force: true });
572
588
  if (untar.status !== 0) {
573
589
  rmSync(staging, { recursive: true, force: true });
574
- fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, republish the app.");
590
+ fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
575
591
  }
576
592
  if (existsSync(target)) rmSync(target, { recursive: true, force: true });
577
593
  renameSync(staging, target);
@@ -591,11 +607,112 @@ async function pull() {
591
607
  join(target, ".monty", "source.json"),
592
608
  JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
593
609
  );
610
+ // Re-stamp identity (the snapshot excludes .monty/): the workspace row is
611
+ // the authority for id/name/icon.
612
+ writeAppJson(target, {
613
+ ...(app.id ? { id: app.id } : {}),
614
+ slug,
615
+ ...(app.name ? { name: app.name } : {}),
616
+ ...(app.icon ? { icon: app.icon } : {}),
617
+ });
594
618
  console.log(`pulled: ${target}`);
595
619
  console.log("next: `monty install`, then `monty dev`.");
596
620
  }
597
621
 
598
622
  // ── monty create ───────────────────────────────────────────────────────────
623
+ // A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
624
+ // the platform shell renders it. The presence of index.html is the marker
625
+ // (every SPA template ships one; the config-only scaffold never does).
626
+ function isConfigOnlyApp(appDir) {
627
+ return !existsSync(join(appDir, "index.html"));
628
+ }
629
+
630
+ // Literal read of the config's `schedule` block ({ fn: "cron expr" }) —
631
+ // the same light-touch parse the SDK's vite plugin uses for publicFns.
632
+ // Registry-owned sessions read it this way so the cron ticker works
633
+ // without a config compile.
634
+ function readScheduleLiteral(appDir) {
635
+ try {
636
+ const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
637
+ const block = /schedule:\s*{([^}]*)}/m.exec(src)?.[1];
638
+ if (!block) return undefined;
639
+ const out = {};
640
+ for (const m of block.matchAll(/["']?([a-zA-Z][a-zA-Z0-9_]*)["']?\s*:\s*"([^"]+)"/g)) {
641
+ out[m[1]] = m[2];
642
+ }
643
+ return Object.keys(out).length > 0 ? out : undefined;
644
+ } catch {
645
+ return undefined;
646
+ }
647
+ }
648
+
649
+ // The code half's custom pages, by file convention: every TOP-LEVEL route
650
+ // file in src/routes/ is a page (nested routes — dot-separated names — are a
651
+ // page's inner paths). `__root`/`index` are the SPA's own plumbing, never
652
+ // pages. Saves register these as fnsJson.pages; a running session serves
653
+ // every declared page directly.
654
+ function discoverPages(appDir) {
655
+ const routesDir = join(appDir, "src", "routes");
656
+ if (!existsSync(routesDir)) return [];
657
+ return readdirSync(routesDir)
658
+ .filter((f) => /\.(tsx|jsx)$/.test(f))
659
+ .map((f) => f.replace(/\.(tsx|jsx)$/, ""))
660
+ .filter((name) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name) && name !== "index")
661
+ .sort()
662
+ .map((name) => ({ name, path: `/${name}` }));
663
+ }
664
+
665
+ const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
666
+
667
+ The app is rendered by the Monty platform from its WORKSPACE manifest —
668
+ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
669
+ \`settings\`, and \`pages\`. There is no src/, no React, no build.
670
+
671
+ - The schema lives in the workspace, and its door is the schema API:
672
+ read it with \`monty schema\`, change it with \`monty schema set <file|->\`
673
+ (validated, CAS-guarded, live within seconds). monty.config.ts edits do
674
+ NOT change a workspace-owned app's schema.
675
+ - Formulas are strings in the Monty expression grammar, e.g.
676
+ \`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
677
+ ABOVE the formula and \`metrics.<name>\` are in scope.
678
+ - Need a bespoke page later? \`monty add page\` declares it and upgrades this
679
+ app with a SPA scaffold; \`monty save\` ships the code.
680
+ `;
681
+
682
+ function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
683
+ mkdirSync(target, { recursive: true });
684
+ writeFileSync(join(target, "monty.config.ts"), `import { defineApp } from "@montytools/sdk";
685
+
686
+ // This file IS the app: tables, derived fields, metrics, settings, pages.
687
+ // The Monty platform renders it — no src/, no build. Declare tables as zod
688
+ // objects; derive with rollup()/lookup()/formula(); see AGENTS.md.
689
+ export const app = defineApp({
690
+ id: "${appId}",
691
+ slug: "${slug}",
692
+ name: "${name}",
693
+ icon: "${icon}",
694
+ tables: {},
695
+ });
696
+
697
+ export type App = typeof app;
698
+ `);
699
+ writeFileSync(join(target, "package.json"), JSON.stringify({
700
+ name: slug,
701
+ private: true,
702
+ type: "module",
703
+ dependencies: { "@montytools/sdk": "latest", zod: "^4.4.3" },
704
+ }, null, 2) + "\n");
705
+ writeFileSync(join(target, "tsconfig.json"), JSON.stringify({
706
+ compilerOptions: {
707
+ target: "ES2022", module: "ESNext", moduleResolution: "bundler",
708
+ strict: true, skipLibCheck: true, noEmit: true,
709
+ },
710
+ include: ["monty.config.ts"],
711
+ }, null, 2) + "\n");
712
+ writeFileSync(join(target, ".gitignore"), "node_modules/\n.monty/\n");
713
+ writeFileSync(join(target, "AGENTS.md"), CONFIG_ONLY_AGENTS_MD);
714
+ }
715
+
599
716
  async function create() {
600
717
  const slug = rest.find((a) => !a.startsWith("--"));
601
718
  if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
@@ -652,6 +769,51 @@ async function create() {
652
769
  }
653
770
  mkdirSync(dirname(target), { recursive: true });
654
771
 
772
+ // DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
773
+ // platform shell renders it; \`monty add page\` scaffolds a SPA the moment a
774
+ // bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
775
+ // (\`--config-only\` stays accepted as the now-default no-op).
776
+ if (!rest.includes("--spa")) {
777
+ console.log(`create: ${slug} -> ${target} (config-only)`);
778
+ writeConfigOnlyScaffold(target, { appId, slug, name, icon });
779
+ // The identity stamp is the app-root marker and identity source from
780
+ // here on — the config file is just code.
781
+ writeAppJson(target, { id: appId, slug, name, icon });
782
+ // The user's brief lands at the top of AGENTS.md, same as SPA creates.
783
+ const brief = flag("description");
784
+ if (brief?.trim()) {
785
+ const agentsPath = join(target, "AGENTS.md");
786
+ writeFileSync(
787
+ agentsPath,
788
+ `# What to build: ${name}\n\n${brief.trim()}\n\n---\n\n` + readFileSync(agentsPath, "utf8"),
789
+ );
790
+ console.log("brief: AGENTS.md carries the app description");
791
+ }
792
+ const cBuildId = flag("build");
793
+ if (cBuildId && /^[a-z0-9]{10,64}$/i.test(cBuildId)) {
794
+ mkdirSync(join(target, ".monty"), { recursive: true });
795
+ writeFileSync(join(target, ".monty", "build"), cBuildId + "\n");
796
+ try {
797
+ await fetch(`${host}/api/build`, {
798
+ method: "POST",
799
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
800
+ body: JSON.stringify({ buildId: cBuildId, slug }),
801
+ });
802
+ } catch { /* progress signal only */ }
803
+ }
804
+ if (!existsSync(join(target, "CLAUDE.md"))) {
805
+ try {
806
+ symlinkSync("AGENTS.md", join(target, "CLAUDE.md"));
807
+ } catch {
808
+ writeFileSync(join(target, "CLAUDE.md"), "@AGENTS.md\n");
809
+ }
810
+ }
811
+ installSkills({ appDir: target });
812
+ console.log(`created: ${target}`);
813
+ console.log(`next: cd ${target} && monty install && monty dev`);
814
+ return;
815
+ }
816
+
655
817
  // Template is bundled into the published package (../template). Fall back to
656
818
  // the monorepo path when running the CLI in-place during development.
657
819
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -676,7 +838,7 @@ async function create() {
676
838
  // hard-excludes the real name from tarballs); the in-repo template has the
677
839
  // real file. Normalize, and backfill for bundles that carried neither —
678
840
  // without a .gitignore, tailwind v4's content scan includes .monty/ and
679
- // full-reloads Studio on every dev.json touch.
841
+ // full-reloads the session on every dev.json touch.
680
842
  const gitignorePath = join(target, ".gitignore");
681
843
  if (existsSync(join(target, "gitignore"))) {
682
844
  renameSync(join(target, "gitignore"), gitignorePath);
@@ -704,6 +866,9 @@ async function create() {
704
866
  htmlPath,
705
867
  readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
706
868
  );
869
+ // The identity stamp is the app-root marker and identity source from
870
+ // here on — the config file is just code the bundle imports.
871
+ writeAppJson(target, { id: appId, slug, name, icon });
707
872
 
708
873
  // The user's brief (--description, e.g. from the desktop's create dialog)
709
874
  // goes to the TOP of AGENTS.md — agent harnesses can't be handed an initial
@@ -819,7 +984,7 @@ async function freePort(start) {
819
984
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
820
985
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
821
986
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
822
- const MIN_SDK = "0.1.5";
987
+ const MIN_SDK = "0.2.1";
823
988
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
824
989
 
825
990
  function installedSdkVersion(appDir) {
@@ -1099,12 +1264,12 @@ function printAttach(appDir, s) {
1099
1264
  const beat = typeof s.lastHeartbeatAt === "number" ? Math.round((Date.now() - s.lastHeartbeatAt) / 1000) : null;
1100
1265
  console.log(
1101
1266
  beat !== null && beat > Math.round(DEV_JSON_STALE_MS / 1000)
1102
- ? `state: online (no heartbeat for ${beat}s — Studio may show offline)`
1267
+ ? `state: online (no heartbeat for ${beat}s — the workspace may show the session offline)`
1103
1268
  : `state: online (heartbeat ${beat ?? "?"}s ago)`,
1104
1269
  );
1105
- if (s.studioUrl) console.log(`studio: ${s.studioUrl} — your app runs there while this is up; click Publish to go Live`);
1270
+ if (s.host && s.slug) console.log(`app: ${s.host}/apps/${s.slug} — your app runs there while this is up`);
1106
1271
  } else {
1107
- console.log("state: ready (registering with the workspace — the Studio link appears on the first successful heartbeat)");
1272
+ console.log("state: ready (registering with the workspace — the app link appears on the first successful heartbeat)");
1108
1273
  }
1109
1274
  }
1110
1275
  console.log("logs: `monty logs -f` follows output; `monty logs -n 100` shows recent lines");
@@ -1226,11 +1391,11 @@ async function sweepOrphans(s) {
1226
1391
  }
1227
1392
 
1228
1393
  // ── monty dev ──────────────────────────────────────────────────────────────
1229
- // Puts the app in Studio: vite locally + a Cloudflare quick tunnel registered
1230
- // as the app's STUDIO channel, so workspace admins see the app (HMR included)
1231
- // at usemonty.dev while it runs. Data is #dev-sandboxed automatically (vite
1232
- // dev build). The heartbeat doubles as the publish poll: when an owner
1233
- // clicks Publish in the workspace, this process builds + uploads to Live.
1394
+ // Runs the app's session: vite locally + a Cloudflare quick tunnel
1395
+ // registered as the app's session channel, so workspace admins see the
1396
+ // app (HMR included) at usemonty.dev while it runs. One data namespace:
1397
+ // the session reads and writes the app's REAL records editing is the
1398
+ // change going live. `monty save` ships code.
1234
1399
  async function dev() {
1235
1400
  const appDir = requireAppDir("dev");
1236
1401
 
@@ -1279,43 +1444,67 @@ async function dev() {
1279
1444
 
1280
1445
  installSkills({ appDir });
1281
1446
  ensureSdk(appDir);
1282
- const meta = await compileConfig(appDir);
1447
+ // Registry-owned apps (stamped on the first configIgnored beat): the
1448
+ // platform reads NOTHING from the config compile — identity comes from
1449
+ // the stamp and the compile is skipped entirely. The config file is just
1450
+ // code the bundle imports; `schedule` (a code-door declaration the
1451
+ // session cron ticker needs) is read literally, the same way the vite
1452
+ // plugin reads `publicFns`.
1453
+ const stamp = readAppJson(appDir);
1454
+ const registryOwned = stamp?.registryOwned === true && typeof stamp?.slug === "string";
1455
+ const meta = registryOwned
1456
+ ? { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) }
1457
+ : await compileConfig(appDir);
1283
1458
  const cfg = loadConfig();
1284
1459
  const host = cfg?.host ?? DEFAULT_HOST;
1460
+ // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
1461
+ // compile + push — the platform shell renders the app.
1462
+ const configOnly = isConfigOnlyApp(appDir);
1285
1463
  // Auto-pick a free port (agents run several apps side by side); an
1286
1464
  // explicit --port is honored strictly.
1287
1465
  const requested = flag("port");
1288
- const port = requested ? Number(requested) : await freePort(5173);
1466
+ const port = configOnly ? null : requested ? Number(requested) : await freePort(5173);
1289
1467
 
1290
- const viteBin = resolveViteBin(appDir);
1291
- if (!viteBin) {
1292
- fail("VITE_MISSING", "vite is not installed in this app. Run `monty install`, then `monty dev` again.");
1468
+ let child = null;
1469
+ if (!configOnly) {
1470
+ const viteBin = resolveViteBin(appDir);
1471
+ if (!viteBin) {
1472
+ fail("VITE_MISSING", "vite is not installed in this app. Run `monty install`, then `monty dev` again.");
1473
+ }
1474
+ console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
1475
+ child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
1476
+ cwd: appDir,
1477
+ stdio: ["ignore", "pipe", "pipe"],
1478
+ });
1479
+ } else {
1480
+ console.log(`dev: config-only app "${meta.slug}" — no vite; watching monty.config.ts`);
1293
1481
  }
1294
1482
 
1295
- console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
1296
- const child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
1297
- cwd: appDir,
1298
- stdio: ["ignore", "pipe", "pipe"],
1299
- });
1300
-
1301
1483
  let tunnelChild = null;
1302
- let pubChild = null;
1303
1484
  let hbTimer = null;
1304
1485
  let touchTimer = null;
1305
1486
  let cronTimer = null;
1306
- let publishing = false;
1307
1487
  let ended = false;
1308
1488
  let registeredOnce = false;
1489
+ // Held-config warnings print once per drift episode, not every beat.
1490
+ let driftAnnounced = false;
1491
+ // The registry-owned notice prints once per session.
1492
+ let configIgnoredAnnounced = false;
1309
1493
  const devStartedAt = Date.now();
1310
1494
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
1311
1495
  const buildFile = join(appDir, ".monty", "build");
1312
1496
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
1313
- // The STUDIO schema channel: heartbeats carry the compiled schema, and
1314
- // edits to monty.config.ts are re-compiled (softly) so schema changes reach
1315
- // the platform within one heartbeat. Publish owns the LIVE schema.
1497
+ // The session schema channel (manifest-less apps): heartbeats carry the
1498
+ // compiled schema, and edits to monty.config.ts are re-compiled (softly)
1499
+ // so schema changes reach the platform within one heartbeat.
1500
+ // Registry-owned apps skip all of it — their schema lives behind the
1501
+ // doors, and the local config copy follows the registry (auto-pull below).
1316
1502
  let currentMeta = meta;
1503
+ // Once per registry change: the manifest hash we last tried to sync the
1504
+ // local config copy to (successful or refused — never loop on dirty).
1505
+ let syncAttemptedHash = null;
1317
1506
  const configPath = join(appDir, "monty.config.ts");
1318
- let configMtime = statSync(configPath).mtimeMs;
1507
+ let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
1319
1508
 
1320
1509
  // Advertise this session. The touch timer (not the platform heartbeat,
1321
1510
  // which starts minutes late or never when logged out) keeps updatedAt
@@ -1335,11 +1524,11 @@ async function dev() {
1335
1524
  sessionId,
1336
1525
  state: "starting",
1337
1526
  loggedIn,
1338
- appUrl: `http://localhost:${port}`,
1527
+ appUrl: configOnly ? null : `http://localhost:${port}`,
1339
1528
  tunnelUrl: null,
1340
- studioUrl: loggedIn ? `${host}/studio/${meta.slug}` : null,
1341
- previewUrl: loggedIn
1342
- ? `${host}/studio/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1529
+ // Field names are wire contract (the desktop reads them).
1530
+ previewUrl: loggedIn && !configOnly
1531
+ ? `${host}/apps/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1343
1532
  : null,
1344
1533
  publishing: false,
1345
1534
  lastHeartbeatAt: null,
@@ -1348,7 +1537,7 @@ async function dev() {
1348
1537
  });
1349
1538
  touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
1350
1539
 
1351
- // The STUDIO cron runner: the Live counterpart is a real Cloudflare Cron
1540
+ // The session cron runner: the Live counterpart is a real Cloudflare Cron
1352
1541
  // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1353
1542
  // `schedule` entries against the UTC clock once per minute and invokes the
1354
1543
  // fn through the same /__monty/fn runtime (x-monty-schedule marks the
@@ -1384,6 +1573,7 @@ async function dev() {
1384
1573
  cronTimer = setInterval(cronTick, 20_000);
1385
1574
 
1386
1575
  async function refreshSchemaIfChanged() {
1576
+ if (registryOwned) return; // the doors own the schema; nothing to push
1387
1577
  try {
1388
1578
  const m = statSync(configPath).mtimeMs;
1389
1579
  if (m === configMtime) return;
@@ -1392,7 +1582,7 @@ async function dev() {
1392
1582
  if (fresh) {
1393
1583
  currentMeta = fresh;
1394
1584
  console.log(
1395
- `schema: monty.config.ts changed — Studio schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1585
+ `schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1396
1586
  );
1397
1587
  }
1398
1588
  } catch { /* transient fs hiccup — next beat retries */ }
@@ -1417,11 +1607,10 @@ async function dev() {
1417
1607
  if (hbTimer) clearInterval(hbTimer);
1418
1608
  if (touchTimer) clearInterval(touchTimer);
1419
1609
  if (cronTimer) clearInterval(cronTimer);
1420
- try { pubChild?.kill(); } catch { /* already gone */ }
1421
1610
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1422
1611
  // vite is a direct child (no npx wrapper), so this actually kills it —
1423
1612
  // a bare SIGTERM from the desktop must never orphan vite on the port.
1424
- try { child.kill(); } catch { /* already gone */ }
1613
+ try { child?.kill(); } catch { /* already gone */ }
1425
1614
  sf.remove();
1426
1615
  logSink.close();
1427
1616
  await clearDevSession();
@@ -1433,9 +1622,8 @@ async function dev() {
1433
1622
  if (hbTimer) clearInterval(hbTimer);
1434
1623
  if (touchTimer) clearInterval(touchTimer);
1435
1624
  if (cronTimer) clearInterval(cronTimer);
1436
- try { pubChild?.kill(); } catch { /* already gone */ }
1437
1625
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1438
- try { child.kill(); } catch { /* already gone */ }
1626
+ try { child?.kill(); } catch { /* already gone */ }
1439
1627
  console.log(`dev-session: superseded — ${fix}`);
1440
1628
  sf.remove(); // guarded — never deletes the new owner's file
1441
1629
  logSink.close();
@@ -1455,7 +1643,7 @@ async function dev() {
1455
1643
  headers: { authorization: `Bearer ${liveKey}`, "content-type": "application/json" },
1456
1644
  body: JSON.stringify({
1457
1645
  slug: meta.slug,
1458
- tunnelUrl: originUrl,
1646
+ ...(originUrl !== undefined ? { tunnelUrl: originUrl } : {}),
1459
1647
  sessionId,
1460
1648
  // Keep claiming until the first successful registration, but only
1461
1649
  // within the lock's own 90s TTL window: after a takeover/crash the
@@ -1468,9 +1656,19 @@ async function dev() {
1468
1656
  icon: currentMeta.icon,
1469
1657
  buildId,
1470
1658
  schemaJson: currentMeta.schemaJson,
1659
+ // App Manifest v2 (docs/manifest-v2.md) — present only for V2
1660
+ // configs; lands only for manifest-less apps (the doors own the
1661
+ // rest).
1662
+ manifest: currentMeta.manifest,
1663
+ // The CAS base for the manifest-less landing (see `monty schema
1664
+ // pull`).
1665
+ baseManifestHash:
1666
+ currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
1471
1667
  // The expose block rides the same compile as the schema — the dev
1472
- // visitor preview is gated on it (devExposureJson).
1668
+ // visitor preview of a manifest-less app is gated on it.
1473
1669
  exposure: currentMeta.exposure,
1670
+ // Rules ride the same channel (manifest-less apps only).
1671
+ rules: currentMeta.rules,
1474
1672
  }),
1475
1673
  signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
1476
1674
  });
@@ -1480,6 +1678,11 @@ async function dev() {
1480
1678
  stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
1481
1679
  return false;
1482
1680
  }
1681
+ if (data?.code === "MANIFEST_DRIFT") {
1682
+ // Remote schema edits (another agent, via the API) — the terminal
1683
+ // the building agent is watching gets the fix, invariant #4.
1684
+ console.log(`schema drift (remote changes):${data.summary ? `\n${data.summary}` : ""}`);
1685
+ }
1483
1686
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
1484
1687
  // A dead key is a SIGNED-OUT session — advertise it so the desktop
1485
1688
  // (which owns the session) can surface sign-in instead of letting
@@ -1489,43 +1692,77 @@ async function dev() {
1489
1692
  }
1490
1693
  return false;
1491
1694
  }
1695
+ if (data?.manifestDrift) {
1696
+ // The session registered, but the config push was HELD: the stored
1697
+ // schema changed since this checkout last synced (another editor).
1698
+ if (!driftAnnounced) {
1699
+ driftAnnounced = true;
1700
+ console.log(`schema drift (remote changes):${data.manifestDrift.summary ? `\n${data.manifestDrift.summary}` : ""}`);
1701
+ console.log(`config push held: ${data.manifestDrift.fix ?? "Run `monty schema pull`, merge, then save again."}`);
1702
+ }
1703
+ } else if (currentMeta.manifest !== undefined) {
1704
+ driftAnnounced = false;
1705
+ // This beat's manifest landed LIVE — the new CAS base.
1706
+ try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
1707
+ }
1492
1708
  if (!registeredOnce) {
1493
1709
  registeredOnce = true;
1494
1710
  sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
1495
1711
  } else {
1496
1712
  sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1497
1713
  }
1498
- if (data?.publishRequested && !publishing && !ended) {
1499
- publishing = true;
1500
- sf.write({ publishing: true });
1501
- console.log("publish: requested from the workspace building & uploading…");
1502
- const pubTee = logSink.source();
1503
- await new Promise((resolve) => {
1504
- pubChild = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
1505
- cwd: appDir,
1506
- stdio: ["ignore", "pipe", "pipe"],
1714
+ if (data?.configIgnored && !configIgnoredAnnounced) {
1715
+ configIgnoredAnnounced = true;
1716
+ console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set <file>`.");
1717
+ // Stamp registry ownership: from the next session on, the config
1718
+ // compile is skipped entirely.
1719
+ try {
1720
+ writeAppJson(appDir, {
1721
+ registryOwned: true,
1722
+ slug: meta.slug,
1723
+ ...(currentMeta.name ? { name: currentMeta.name } : {}),
1724
+ ...(currentMeta.icon ? { icon: currentMeta.icon } : {}),
1507
1725
  });
1508
- pubChild.stdout.on("data", (c) => {
1509
- process.stdout.write(c);
1510
- pubTee(c);
1511
- });
1512
- pubChild.stderr.on("data", (c) => {
1513
- process.stderr.write(c);
1514
- pubTee(c);
1515
- });
1516
- pubChild.on("exit", (code) => {
1517
- pubChild = null;
1518
- pubTee.flush();
1519
- console.log(
1520
- code === 0
1521
- ? "publish: done — the app is Live for the workspace (Studio session continues)"
1522
- : "publish: FAILED fix the errors above, then click Publish again",
1523
- );
1524
- resolve(undefined);
1525
- });
1526
- });
1527
- publishing = false;
1528
- sf.write({ publishing: false });
1726
+ } catch { /* the stamp is a convenience; the beat decided */ }
1727
+ }
1728
+ // The local config copy follows the registry: when the stored manifest
1729
+ // moved (another editor, the schema door) and the local file is clean,
1730
+ // regenerate it in place — the sync of the mechanical copy is
1731
+ // automatic. Dirty or uncompilable files are left alone with the fix.
1732
+ if (
1733
+ typeof data?.manifestHash === "string" &&
1734
+ existsSync(configPath) &&
1735
+ data.manifestHash !== readSchemaState(appDir)?.hash &&
1736
+ data.manifestHash !== syncAttemptedHash
1737
+ ) {
1738
+ syncAttemptedHash = data.manifestHash;
1739
+ try {
1740
+ // Already in sync (fresh checkout, no state file yet)? Stamp the
1741
+ // base and leave the file alone — regenerate only on real drift.
1742
+ let alreadySynced = false;
1743
+ try {
1744
+ const compiled = await compileAppConfig(appDir);
1745
+ if (compiled.manifest && manifestHash(compiled.manifest) === data.manifestHash) {
1746
+ writeSchemaState(appDir, data.manifestHash);
1747
+ alreadySynced = true;
1748
+ }
1749
+ } catch { /* uncompilable — let schemaPull's dirty check narrate */ }
1750
+ if (!alreadySynced) {
1751
+ await schemaPull({
1752
+ appDir,
1753
+ host,
1754
+ key: loadConfig()?.key ?? cfg.key,
1755
+ slug: meta.slug,
1756
+ force: false,
1757
+ compileAppConfig,
1758
+ fail: (code, fix) => {
1759
+ throw new Error(`${code} — ${fix}`);
1760
+ },
1761
+ });
1762
+ }
1763
+ } catch (e) {
1764
+ console.log(`schema: the workspace manifest changed but the local copy was NOT regenerated (${String(e?.message ?? e).slice(0, 240)})`);
1765
+ }
1529
1766
  }
1530
1767
  return true;
1531
1768
  } catch {
@@ -1536,11 +1773,12 @@ async function dev() {
1536
1773
 
1537
1774
  async function startDevSession() {
1538
1775
  if (!cfg?.key) {
1539
- console.log("dev: not logged in — workspace Studio disabled (run `monty login`)");
1776
+ console.log("dev: not logged in — workspace session disabled (run `monty login`)");
1540
1777
  return;
1541
1778
  }
1542
1779
  await clearDevSession();
1543
- let originUrl = `http://localhost:${port}`;
1780
+ // Config-only sessions register WITHOUT an origin — nothing to iframe.
1781
+ let originUrl = configOnly ? undefined : `http://localhost:${port}`;
1544
1782
  let tunnelUpdate = Promise.resolve();
1545
1783
  let tunnelVersion = 0;
1546
1784
  async function activateTunnelUrl(url, { initial = false } = {}) {
@@ -1557,17 +1795,17 @@ async function dev() {
1557
1795
  if (ended || version !== tunnelVersion) return "superseded";
1558
1796
  if (!dnsLive) {
1559
1797
  if (initial) {
1560
- console.log("tunnel: DNS never propagated — Studio registered on localhost (visible on this machine's browser only)");
1798
+ console.log("tunnel: DNS never propagated — session registered on localhost (visible on this machine's browser only)");
1561
1799
  } else {
1562
- console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
1800
+ console.log("tunnel: DNS never propagated for the new URL — keeping the session offline until the next tunnel URL");
1563
1801
  }
1564
1802
  return "failed";
1565
1803
  }
1566
1804
  originUrl = url;
1567
1805
  sf.write({ tunnelUrl: url });
1568
- console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
1806
+ console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; session URL updated");
1569
1807
  if (!initial && !(await heartbeat(originUrl))) {
1570
- console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
1808
+ console.log("dev-session: the session still has no registered tunnel; the next heartbeat will retry");
1571
1809
  }
1572
1810
  return "activated";
1573
1811
  }
@@ -1579,7 +1817,7 @@ async function dev() {
1579
1817
  console.log("tunnel: URL update failed — waiting for the next tunnel URL");
1580
1818
  });
1581
1819
  };
1582
- if (!rest.includes("--no-tunnel")) {
1820
+ if (!configOnly && !rest.includes("--no-tunnel")) {
1583
1821
  console.log("tunnel: starting (cloudflared quick tunnel)…");
1584
1822
  // cloudflared output is teed to dev.log only (the terminal stays quiet,
1585
1823
  // exactly as today) — post-mortems get the tunnel noise.
@@ -1591,22 +1829,46 @@ async function dev() {
1591
1829
  console.log(`tunnel: ${t.url}`);
1592
1830
  await activateTunnelUrl(t.url, { initial: true });
1593
1831
  } else {
1594
- console.log("tunnel: unavailable — Studio registered on localhost (visible on this machine's browser only)");
1832
+ console.log("tunnel: unavailable — session registered on localhost (visible in this machine's browser only)");
1595
1833
  }
1596
1834
  }
1597
1835
  const registered = await heartbeat(originUrl, { claim: true });
1598
1836
  console.log(
1599
1837
  registered
1600
- ? `studio: ${host}/studio/${meta.slug} — your app runs there while this is up; click Publish to go Live`
1601
- : `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
1838
+ ? `preview: ${host}/apps/${meta.slug} — the app surface follows this session while it runs; \`monty save\` updates the cloud copy`
1839
+ : `preview: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
1602
1840
  );
1603
1841
  hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
1604
1842
  }
1605
1843
 
1844
+ if (configOnly) {
1845
+ sf.write({ state: "ready" });
1846
+ console.log(
1847
+ registryOwned
1848
+ ? "ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`"
1849
+ : "ready: config-only — saves land LIVE; the workspace renders them within seconds",
1850
+ );
1851
+ void startDevSession();
1852
+ if (!registryOwned) {
1853
+ // Manifest-less apps only: config edits should land in seconds, not a
1854
+ // heartbeat — watch the mtime and trigger an early beat (which
1855
+ // recompiles + pushes). Registry-owned apps have nothing to push.
1856
+ const cfgWatch = setInterval(() => {
1857
+ try {
1858
+ if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
1859
+ } catch { /* transient fs hiccup */ }
1860
+ }, 2000);
1861
+ const stopWatch = () => clearInterval(cfgWatch);
1862
+ process.on("SIGINT", stopWatch);
1863
+ process.on("SIGTERM", stopWatch);
1864
+ process.on("SIGHUP", stopWatch);
1865
+ }
1866
+ }
1867
+
1606
1868
  let announced = false;
1607
1869
  const viteOutTee = logSink.source();
1608
1870
  const viteErrTee = logSink.source();
1609
- child.stdout.on("data", (chunk) => {
1871
+ child?.stdout.on("data", (chunk) => {
1610
1872
  const text = chunk.toString();
1611
1873
  process.stdout.write(text);
1612
1874
  viteOutTee(chunk);
@@ -1616,23 +1878,23 @@ async function dev() {
1616
1878
  if (!announced && /localhost:\d+/.test(text.replace(ANSI_RE, ""))) {
1617
1879
  announced = true;
1618
1880
  sf.write({ state: "ready" });
1619
- console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
1881
+ console.log(`data: live this session reads and writes the app\'s real records`);
1620
1882
  console.log(`ready: http://localhost:${port}`);
1621
1883
  void startDevSession();
1622
1884
  }
1623
1885
  });
1624
1886
  // vite stderr is where build errors and the SDK's browser-error mirror
1625
1887
  // land — piped (was inherit) so `monty logs` sees them too.
1626
- child.stderr.on("data", (chunk) => {
1888
+ child?.stderr.on("data", (chunk) => {
1627
1889
  process.stderr.write(chunk);
1628
1890
  viteErrTee(chunk);
1629
1891
  });
1630
- child.on("error", (e) => {
1892
+ child?.on("error", (e) => {
1631
1893
  void endSession().then(() => {
1632
1894
  fail("VITE_SPAWN_FAILED", `Could not start vite: ${e?.message ?? e}. Run \`monty install\`, then retry.`);
1633
1895
  });
1634
1896
  });
1635
- child.on("exit", (code) => {
1897
+ child?.on("exit", (code) => {
1636
1898
  viteOutTee.flush();
1637
1899
  viteErrTee.flush();
1638
1900
  void endSession().then(() => process.exit(code ?? 0));
@@ -1806,7 +2068,7 @@ async function waitForDns(hostname) {
1806
2068
 
1807
2069
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
1808
2070
  // binary on first use). Resolves with the public URL, or null on failure —
1809
- // Studio then falls back to localhost-only registration. onOutput receives
2071
+ // the session then falls back to localhost-only registration. onOutput receives
1810
2072
  // every chunk (both fds) for the dev.log tee.
1811
2073
  function startTunnel(port, onUrlChange, onOutput) {
1812
2074
  return new Promise((resolve) => {
@@ -1881,11 +2143,237 @@ function resolveComponent(name) {
1881
2143
  return name;
1882
2144
  }
1883
2145
 
2146
+ // ── monty add page <name> ──────────────────────────────────────────────────
2147
+ // Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
2148
+ // use (config-only apps gain src/ + vite from the template — their
2149
+ // monty.config.ts and AGENTS.md stay untouched), declares
2150
+ // `pages.<name> = { kind: "custom", path: "/<name>" }`, and writes the page
2151
+ // route. DECLARE-FIRST: on a workspace-owned app the entry lands through
2152
+ // the schema door BEFORE the code exists — a save carrying an undeclared
2153
+ // route refuses (DEPLOY_UNDECLARED_PAGE). The Shopify model: system pages
2154
+ // stay shell-rendered; only this page is the app's own code.
2155
+
2156
+ // Declare the page through the schema door. Returns "declared" | "already"
2157
+ // (workspace-owned app) or "config" (manifest-less: the config file is
2158
+ // still that app's editor, the caller registers the entry there).
2159
+ async function declarePageThroughDoor(appDir, pageName) {
2160
+ const slug = readSlug(appDir);
2161
+ const { host, key } = loadConfig();
2162
+ if (!slug || !key) return "config";
2163
+ const read = await fetch(`${host}/api/schema?slug=${slug}`, {
2164
+ headers: { authorization: `Bearer ${key}` },
2165
+ }).catch(() => null);
2166
+ const readBody = await read?.json().catch(() => null);
2167
+ if (!read?.ok || !readBody?.ok || readBody.manifest === null) return "config";
2168
+ const manifest = readBody.manifest;
2169
+ const keyOf = (n) => n.toLowerCase().replace(/[^a-z0-9]/g, "");
2170
+ const declared = Object.keys(manifest.pages ?? {}).find((n) => keyOf(n) === keyOf(pageName));
2171
+ if (declared) {
2172
+ const kind = manifest.pages[declared]?.kind;
2173
+ if (kind !== "custom") {
2174
+ fail("PAGE_KIND", `"${declared}" is a ${kind} page in the app's manifest — custom code can't replace it. Pick another page name.`);
2175
+ }
2176
+ return "already";
2177
+ }
2178
+ // An empty pages block means default view pages derive from the tables;
2179
+ // materialize them first so declaring one custom page can't hide the rest.
2180
+ const pages =
2181
+ manifest.pages && Object.keys(manifest.pages).length > 0
2182
+ ? { ...manifest.pages }
2183
+ : Object.fromEntries(Object.keys(manifest.tables ?? {}).map((t) => [t, { kind: "view", table: t }]));
2184
+ pages[pageName] = { kind: "custom", path: `/${pageName}` };
2185
+ const res = await fetch(`${host}/api/schema`, {
2186
+ method: "POST",
2187
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
2188
+ body: JSON.stringify({ slug, manifest: { ...manifest, pages }, baseHash: readBody.hash }),
2189
+ });
2190
+ const body = await res.json().catch(() => null);
2191
+ if (!res.ok || !body?.ok) {
2192
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Declaring the page through the schema door failed — check the connection and retry.");
2193
+ }
2194
+ try { writeSchemaState(appDir, body.hash); } catch { /* state is advisory */ }
2195
+ return "declared";
2196
+ }
2197
+
2198
+ async function addPage(appDir, pageName) {
2199
+ if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
2200
+ fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2201
+ }
2202
+ const configPath = join(appDir, "monty.config.ts");
2203
+ if (!existsSync(configPath)) {
2204
+ fail("NO_CONFIG", `No monty.config.ts in ${appDir} — run this inside a Monty app.`);
2205
+ }
2206
+ const config = readFileSync(configPath, "utf8");
2207
+ const appName = config.match(/name: "([^"]*)"/)?.[1] ?? pageName;
2208
+ const routeFile = join(appDir, "src", "routes", `${pageName}.tsx`);
2209
+ if (existsSync(routeFile)) {
2210
+ fail("PAGE_EXISTS", `src/routes/${pageName}.tsx already exists. Edit it, or pick a different page name.`);
2211
+ }
2212
+ if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
2213
+ console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
2214
+ }
2215
+ // Declare BEFORE any code exists: if the door refuses, nothing to clean up.
2216
+ const declared = await declarePageThroughDoor(appDir, pageName);
2217
+ if (declared !== "config") {
2218
+ console.log(
2219
+ declared === "declared"
2220
+ ? `declared: pages.${pageName} through the schema door — live in the workspace now`
2221
+ : `declared: pages.${pageName} already in the workspace manifest`,
2222
+ );
2223
+ }
2224
+
2225
+ // First custom page on a config-only app: bring in the SPA scaffold.
2226
+ if (isConfigOnlyApp(appDir)) {
2227
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
2228
+ const templateDir = [
2229
+ join(pkgRoot, "template"),
2230
+ join(pkgRoot, "..", "template"),
2231
+ ].find((d) => existsSync(join(d, "monty.config.ts")));
2232
+ if (!templateDir) {
2233
+ fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
2234
+ }
2235
+ console.log(`add page: upgrading config-only app with the SPA scaffold`);
2236
+ cpSync(templateDir, appDir, {
2237
+ recursive: true,
2238
+ force: false, // existing files (config, AGENTS.md, package.json…) win
2239
+ filter: (src) => {
2240
+ const base = basename(src);
2241
+ if (["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base)) return false;
2242
+ // The app keeps its own identity files.
2243
+ if (["monty.config.ts", "AGENTS.md", "CLAUDE.md"].includes(base)) return false;
2244
+ return true;
2245
+ },
2246
+ });
2247
+ // gitignore ships name-mangled in the published bundle (see create()).
2248
+ if (existsSync(join(appDir, "gitignore")) && !existsSync(join(appDir, ".gitignore"))) {
2249
+ renameSync(join(appDir, "gitignore"), join(appDir, ".gitignore"));
2250
+ }
2251
+ // Merge the template's SPA deps/scripts into the app's package.json —
2252
+ // the config-only one has only sdk+zod and no scripts.
2253
+ const tplPkg = JSON.parse(readFileSync(join(templateDir, "package.json"), "utf8"));
2254
+ const pkgPath = join(appDir, "package.json");
2255
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
2256
+ pkg.scripts = { ...tplPkg.scripts, ...pkg.scripts };
2257
+ pkg.dependencies = { ...tplPkg.dependencies, ...pkg.dependencies };
2258
+ pkg.devDependencies = { ...tplPkg.devDependencies, ...pkg.devDependencies };
2259
+ // vite.config.ts imports the ESM-only sdk plugin — without the
2260
+ // template's module type the config loads as CJS and fails to resolve it.
2261
+ if (tplPkg.type && !pkg.type) pkg.type = tplPkg.type;
2262
+ // The published template pins the sdk; a workspace app may carry
2263
+ // workspace:* — the merge above keeps the app's existing pin either way.
2264
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
2265
+ const htmlPath = join(appDir, "index.html");
2266
+ if (existsSync(htmlPath)) {
2267
+ writeFileSync(htmlPath, readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${appName}</title>`));
2268
+ }
2269
+ // Client env for the SPA half, same as create().
2270
+ if (!existsSync(join(appDir, ".env.local"))) {
2271
+ try {
2272
+ const { host } = loadConfig();
2273
+ const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
2274
+ writeFileSync(join(appDir, ".env.local"), `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`);
2275
+ console.log("config: .env.local written");
2276
+ } catch {
2277
+ console.log("config: WARNING — could not fetch client config; copy .env.example to .env.local manually");
2278
+ }
2279
+ }
2280
+ // The template's starter index route assumes it IS the app — for a page
2281
+ // upgrade the shell owns the app; drop the starter so the page below is
2282
+ // the only route beside __root.
2283
+ const starter = join(appDir, "src", "routes", "index.tsx");
2284
+ if (existsSync(starter)) rmSync(starter);
2285
+ appendFileSync(
2286
+ join(appDir, "AGENTS.md"),
2287
+ `\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` declaration.\nDECLARE-FIRST: \`monty add page <name>\` declares the entry (through the\nschema door on workspace-owned apps) before writing the route — a save\ncarrying an undeclared route refuses. System pages (table views) stay\nshell-rendered — only build bespoke UI here. \`monty dev\` serves both.\nAfter every meaningful change verified in dev, run\n\`monty save "<what changed>"\` — it pushes the work to the cloud copy,\nlike \`git push main\`.\n\nEvery custom page opens with \`PageHeader\` from \`@montytools/sdk/ui\` —\nthe same bar the shell renders on system pages (page actions go in it as\n\`PageHeaderButton\`s, \`primary\` for the one main action). Also there:\n\`FloatingBar\`/\`FloatingBarButton\` and the Lyra table classes\n\`SURFACE\`/\`THEAD\`/\`TH\`/\`ROW\`/\`CHIP\`.\n`,
2288
+ );
2289
+ }
2290
+
2291
+ // The generated route imports @montytools/sdk/ui, whose Tailwind classes
2292
+ // only compile if the app's CSS scans the sdk dist — older scaffolds
2293
+ // predate the @source line.
2294
+ const cssPath = join(appDir, "src", "index.css");
2295
+ if (existsSync(cssPath)) {
2296
+ const css = readFileSync(cssPath, "utf8");
2297
+ if (!css.includes("@montytools/sdk/dist/ui.js")) {
2298
+ const lines = css.split("\n");
2299
+ let lastImport = -1;
2300
+ lines.forEach((l, i) => { if (/^@import\s/.test(l)) lastImport = i; });
2301
+ lines.splice(lastImport + 1, 0, '@source "../node_modules/@montytools/sdk/dist/ui.js";');
2302
+ writeFileSync(cssPath, lines.join("\n"));
2303
+ console.log("config: src/index.css now scans @montytools/sdk/ui (Tailwind @source)");
2304
+ }
2305
+ }
2306
+
2307
+ // The page route: a real, working start — SDK data hooks, shell-aware.
2308
+ mkdirSync(dirname(routeFile), { recursive: true });
2309
+ const pageTitle = (pageName[0].toUpperCase() + pageName.slice(1)).replace(/-/g, " ");
2310
+ writeFileSync(routeFile, `import { createFileRoute } from "@tanstack/react-router";
2311
+ import { PageHeader } from "@montytools/sdk/ui";
2312
+
2313
+ export const Route = createFileRoute("/${pageName}")({
2314
+ component: ${pageComponentName(pageName)},
2315
+ });
2316
+
2317
+ // A CUSTOM page: bespoke UI mounted inside the platform shell at
2318
+ // /apps/<slug>/${pageName}. Data comes from @montytools/sdk hooks
2319
+ // (useList/useInsert/…) against the same tables the shell renders. The
2320
+ // PageHeader bar is the same chrome system pages wear — keep it first, put
2321
+ // page actions in it (PageHeaderButton).
2322
+ function ${pageComponentName(pageName)}() {
2323
+ return (
2324
+ <div className="flex h-full min-h-dvh flex-col">
2325
+ <PageHeader title="${pageTitle}" />
2326
+ <main className="min-h-0 flex-1 overflow-auto p-6">
2327
+ <p className="text-sm text-muted-foreground">
2328
+ Build this page. It ships with the app on the next \`monty save\`.
2329
+ </p>
2330
+ </main>
2331
+ </div>
2332
+ );
2333
+ }
2334
+ `);
2335
+ console.log(`page: src/routes/${pageName}.tsx`);
2336
+
2337
+ // Manifest-less apps only: the config file is still their editor, so the
2338
+ // entry registers there (workspace-owned apps declared through the door
2339
+ // above — a config edit would be inert).
2340
+ if (declared === "config") {
2341
+ const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
2342
+ let next = null;
2343
+ if (/^(\s*)pages:\s*{/m.test(config)) {
2344
+ next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
2345
+ } else {
2346
+ // No pages block: add one right before the config's closing `});`.
2347
+ const close = config.lastIndexOf("});");
2348
+ if (close !== -1) {
2349
+ next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
2350
+ }
2351
+ }
2352
+ if (next) {
2353
+ writeFileSync(configPath, next);
2354
+ console.log(`config: pages.${pageName} registered in monty.config.ts`);
2355
+ } else {
2356
+ console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
2357
+ }
2358
+ }
2359
+
2360
+ console.log(`added: custom page "${pageName}"`);
2361
+ console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
2362
+ }
2363
+
2364
+ function pageComponentName(pageName) {
2365
+ return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
2366
+ }
2367
+
1884
2368
  async function add() {
1885
2369
  const appDir = requireAppDir("add");
1886
2370
  const names = rest.filter((a) => !a.startsWith("--"));
1887
2371
  if (names.length === 0) {
1888
- fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available.");
2372
+ fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available (or `monty add page <name>` for a custom page).");
2373
+ }
2374
+ if (names[0] === "page") {
2375
+ await addPage(appDir, names[1]);
2376
+ return;
1889
2377
  }
1890
2378
  const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
1891
2379
 
@@ -1960,7 +2448,12 @@ async function docs() {
1960
2448
  // readable back. Read from the arg, then a TTY prompt, then stdin (piping).
1961
2449
  async function secret() {
1962
2450
  const appDir = requireAppDir("secret");
1963
- const meta = await compileConfig(appDir);
2451
+ // Identity comes from the stamp when it exists — no config compile for
2452
+ // one slug read.
2453
+ const stamped = readAppJson(appDir);
2454
+ const meta = typeof stamped?.slug === "string" && stamped.slug
2455
+ ? { slug: stamped.slug }
2456
+ : await compileConfig(appDir);
1964
2457
  const config = loadConfig();
1965
2458
  if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
1966
2459
  const [sub, name] = rest.filter((a) => !a.startsWith("-"));
@@ -1997,13 +2490,25 @@ async function secret() {
1997
2490
  console.log(del ? `secret: removed ${name} from ${meta.slug}` : `secret: set ${name} on ${meta.slug} (write-only; not readable back)`);
1998
2491
  }
1999
2492
 
2000
- // ── monty deploy ───────────────────────────────────────────────────────────
2493
+ // ── monty save ─────────────────────────────────────────────────────────────
2494
+ // Push the working copy to the cloud copy, like `git push main`. `deploy` is
2495
+ // the compat alias; both run the same pipeline (build + typecheck gate every
2496
+ // save, then one multipart POST). The optional message rides the deploy meta
2497
+ // so the platform can narrate the save later.
2001
2498
  async function deploy() {
2002
- const appDir = requireAppDir("deploy");
2499
+ const appDir = requireAppDir(command);
2003
2500
  ensureSdk(appDir);
2004
- if (!rest.includes("--from-dev")) {
2005
- console.log("note: direct deploy ships straight to Live, skipping workspace review — the usual flow is `monty dev` (Studio) + the Publish button in the workspace.");
2006
- }
2501
+ const message = rest.find((a) => !a.startsWith("-")) ?? null;
2502
+ const saveJson = (state) => writeSaveJson(appDir, { message, ...state });
2503
+ saveJson({ status: "saving" });
2504
+ saveFailNote = (code, fix) => saveJson({ status: "error", detail: fix });
2505
+ process.on("exit", (exitCode) => {
2506
+ // A crash that never reached fail() (network throw, unhandled rejection)
2507
+ // must not leave the chip on "Saving…" forever.
2508
+ if (exitCode !== 0 && saveFailNote) {
2509
+ saveJson({ status: "error", detail: "The save stopped before finishing. Run `monty save` again." });
2510
+ }
2511
+ });
2007
2512
  const config = loadConfig();
2008
2513
  if (!config?.key) {
2009
2514
  fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
@@ -2014,17 +2519,40 @@ async function deploy() {
2014
2519
  console.log("compile: monty.config.ts");
2015
2520
  const meta = await compileConfig(appDir);
2016
2521
  console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
2017
-
2018
- // 2) Fail fast locally before any upload. Build FIRST — it also generates
2019
- // src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
2020
- run(appDir, "build", ["npx", "vite", "build"],
2021
- "The production build failed. Read the vite error above; it names the file to fix.");
2022
- run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
2023
- "TypeScript errors above. Fix them in the listed files; `monty deploy` never uploads code that does not compile.");
2522
+ // `monty save "what changed"` — the message rides the meta for the
2523
+ // platform to render as this save's Activity row.
2524
+ if (message) meta.message = message;
2525
+
2526
+ // Registry-owned apps ship IMPLEMENTATION ONLY: manifest, schema, rules,
2527
+ // and exposure are door-owned (the server holds them regardless — not
2528
+ // sending them keeps the wire honest). Code-door declarations
2529
+ // (publicFns, schedule, pages, functions) still ride.
2530
+ const registryOwned = readAppJson(appDir)?.registryOwned === true;
2531
+ if (registryOwned) {
2532
+ delete meta.schemaJson;
2533
+ delete meta.manifest;
2534
+ delete meta.exposure;
2535
+ delete meta.rules;
2536
+ }
2537
+
2538
+ // CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
2539
+ // the platform shell renders the app.
2540
+ const configOnly = isConfigOnlyApp(appDir);
2541
+ if (!configOnly) {
2542
+ // 2) Fail fast locally before any upload. Build FIRST — it also generates
2543
+ // src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
2544
+ run(appDir, "build", ["npx", "vite", "build"],
2545
+ "The production build failed. Read the vite error above; it names the file to fix.");
2546
+ run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
2547
+ "TypeScript errors above. Fix them in the listed files; `monty save` never uploads code that does not compile.");
2548
+ } else if (!registryOwned && !meta.manifest) {
2549
+ fail("MANIFEST_MISSING",
2550
+ "This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
2551
+ }
2024
2552
 
2025
2553
  // 3) Multipart POST to the host.
2026
2554
  const dist = join(appDir, "dist");
2027
- const files = walk(dist);
2555
+ const files = configOnly ? [] : walk(dist);
2028
2556
  const buildFile = join(appDir, ".monty", "build");
2029
2557
  if (existsSync(buildFile)) {
2030
2558
  meta.buildId = readFileSync(buildFile, "utf8").trim();
@@ -2053,9 +2581,15 @@ async function deploy() {
2053
2581
  `schedule targets "${name}" but server/index.ts exports no such function. Export it (e.g. \`export async function ${name}(args, ctx) {…}\`) or remove the entry from monty.config.ts.`);
2054
2582
  }
2055
2583
  }
2056
- meta.fns = serverBundle.fns;
2584
+ // Wire compat: meta.fns stays the UNION (older hosts gate dispatch on
2585
+ // it); meta.datasets is the additive split newer hosts classify with.
2586
+ meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
2587
+ if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
2057
2588
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
2058
- console.log(`fns: bundled ${serverBundle.fns.length} server function(s) (${serverBundle.fns.join(", ")})`);
2589
+ const bundled = [];
2590
+ if (serverBundle.fns.length > 0) bundled.push(`${serverBundle.fns.length} function(s) (${serverBundle.fns.join(", ")})`);
2591
+ if (serverBundle.datasets.length > 0) bundled.push(`${serverBundle.datasets.length} dataset(s) (${serverBundle.datasets.join(", ")})`);
2592
+ console.log(`fns: bundled ${bundled.join(" + ")}`);
2059
2593
  if (publicFns.length > 0) {
2060
2594
  console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
2061
2595
  }
@@ -2063,6 +2597,16 @@ async function deploy() {
2063
2597
  console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
2064
2598
  }
2065
2599
  }
2600
+ // 3a½) Custom pages, by route-file convention (top-level src/routes/
2601
+ // files) — registered as fnsJson.pages so the shell's nav knows what this
2602
+ // bundle ships without a manifest entry.
2603
+ if (!configOnly) {
2604
+ const pages = discoverPages(appDir);
2605
+ if (pages.length > 0) {
2606
+ meta.pages = pages;
2607
+ console.log(`pages: ${pages.map((p) => p.name).join(", ")}`);
2608
+ }
2609
+ }
2066
2610
  // 3b) SOURCE snapshot rides every publish. Without it the platform keeps
2067
2611
  // only the minified bundle and the sole copy of the app's code is this
2068
2612
  // folder — delete it and the source is gone forever. The snapshot is what
@@ -2072,16 +2616,23 @@ async function deploy() {
2072
2616
  {
2073
2617
  const packed = packSource(appDir);
2074
2618
  if (packed === null) {
2075
- console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this publish.");
2619
+ console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this save.");
2076
2620
  } else if (packed.tooLarge) {
2077
2621
  console.log("source: WARNING — snapshot exceeds 10 MB, skipped; `monty pull` will not work for this app. Remove large assets from the app folder.");
2078
2622
  } else {
2079
2623
  sourceHash = packed.hash;
2080
2624
  meta.sourceHash = sourceHash;
2081
2625
  form.set("source", new Blob([packed.buf]), "source.tar.gz");
2082
- console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this publish (restore anywhere: monty pull ${meta.slug})`);
2626
+ console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
2083
2627
  }
2084
2628
  }
2629
+ // V2 schema CAS: prove which stored manifest this checkout last synced,
2630
+ // so an API/MCP edit made meanwhile surfaces as MANIFEST_DRIFT instead of
2631
+ // being clobbered (fix: `monty schema pull`).
2632
+ if (meta.manifest !== undefined) {
2633
+ const state = readSchemaState(appDir);
2634
+ if (state?.hash) meta.baseManifestHash = state.hash;
2635
+ }
2085
2636
  form.set("monty", JSON.stringify(meta));
2086
2637
  let total = 0;
2087
2638
  for (const file of files) {
@@ -2090,7 +2641,11 @@ async function deploy() {
2090
2641
  total += buf.byteLength;
2091
2642
  form.set(rel, new Blob([buf]), rel);
2092
2643
  }
2093
- console.log(`upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`);
2644
+ console.log(
2645
+ configOnly
2646
+ ? `upload: config-only (manifest, no bundle) -> ${config.host}/api/deploy`
2647
+ : `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
2648
+ );
2094
2649
  const res = await fetch(`${config.host}/api/deploy`, {
2095
2650
  method: "POST",
2096
2651
  headers: { authorization: `Bearer ${config.key}` },
@@ -2098,10 +2653,22 @@ async function deploy() {
2098
2653
  });
2099
2654
  const body = await res.json().catch(() => null);
2100
2655
  if (!res.ok || !body?.ok) {
2656
+ if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
2657
+ console.log(`schema drift (remote changes):\n${body.summary}`);
2658
+ }
2101
2659
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
2102
2660
  }
2103
2661
  console.log(`origin: ${body.origin}`);
2104
- console.log(`deployed: ${body.url} (version ${body.version})`);
2662
+ console.log(`saved: ${body.url} (version ${body.version})`);
2663
+ if (body.manifestDrift) {
2664
+ // The code shipped; the config was HELD — the stored schema changed
2665
+ // since this checkout last synced (another editor).
2666
+ console.log(`schema drift (remote changes):${body.manifestDrift.summary ? `\n${body.manifestDrift.summary}` : ""}`);
2667
+ console.log(`config push held: ${body.manifestDrift.fix ?? "Run `monty schema pull`, merge, then deploy again."}`);
2668
+ } else if (meta.manifest !== undefined) {
2669
+ // The manifest just published IS the new CAS base.
2670
+ writeSchemaState(appDir, manifestHash(meta.manifest));
2671
+ }
2105
2672
  // Stamp what was published — pull uses this to tell "unchanged since last
2106
2673
  // sync" from "locally modified".
2107
2674
  if (sourceHash) {
@@ -2110,15 +2677,22 @@ async function deploy() {
2110
2677
  JSON.stringify({ hash: sourceHash, syncedAt: Date.now() }) + "\n",
2111
2678
  );
2112
2679
  }
2680
+ saveFailNote = null;
2681
+ saveJson({ status: "saved" });
2113
2682
  }
2114
2683
 
2115
- // Bundle server/index.ts (if present) into ONE Worker script: a generated
2116
- // entry wraps the app's exports with @montytools/sdk/fn-worker's makeFnWorker,
2117
- // esbuild bundles it for workerd. node: imports are rejected at compile time
2118
- // Live runs on Cloudflare Workers, not Node. Returns { code, fns } or null.
2684
+ // Bundle the app's server code (if present) into ONE Worker script: server/
2685
+ // index.ts (functions custom code) and datasets/index.ts (defineDataset
2686
+ // table feeds) merge into a generated entry wrapped with @montytools/sdk/
2687
+ // fn-worker's makeFnWorker; esbuild bundles it for workerd. node: imports are
2688
+ // rejected at compile time — Live runs on Cloudflare Workers, not Node.
2689
+ // Returns { code, fns, datasets } (per-folder export names) or null.
2119
2690
  async function bundleServerFns(appDir, schedule) {
2120
2691
  const serverEntry = join(appDir, "server", "index.ts");
2121
- if (!existsSync(serverEntry)) return null;
2692
+ const datasetsEntry = join(appDir, "datasets", "index.ts");
2693
+ const hasServer = existsSync(serverEntry);
2694
+ const hasDatasets = existsSync(datasetsEntry);
2695
+ if (!hasServer && !hasDatasets) return null;
2122
2696
  const { build } = await import("esbuild");
2123
2697
  const tmpDir = join(appDir, ".monty");
2124
2698
  mkdirSync(tmpDir, { recursive: true });
@@ -2128,9 +2702,10 @@ async function bundleServerFns(appDir, schedule) {
2128
2702
  // hands back only the matching cron expression, so the worker needs the
2129
2703
  // expression→fn mapping at runtime.
2130
2704
  writeFileSync(entry, [
2131
- `import * as appFns from "../server/index";`,
2705
+ hasServer ? `import * as appFns from "../server/index";` : `const appFns = {};`,
2706
+ hasDatasets ? `import * as appDatasets from "../datasets/index";` : `const appDatasets = {};`,
2132
2707
  `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
2133
- `export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
2708
+ `export default makeFnWorker({ ...appFns, ...appDatasets }, { schedule: ${JSON.stringify(schedule ?? {})} });`,
2134
2709
  ].join("\n"));
2135
2710
  // Fail the deploy if server code reaches for Node built-ins — a Worker
2136
2711
  // can't run them, and a silent runtime crash on Live is the worst outcome.
@@ -2159,15 +2734,20 @@ async function bundleServerFns(appDir, schedule) {
2159
2734
  logLevel: "silent",
2160
2735
  plugins: [banPlatformImports],
2161
2736
  });
2162
- fns = discoverFnExports(serverEntry);
2163
- if (fns.length === 0) {
2164
- fail("NO_FN_EXPORTS", "server/index.ts exists but exports no async functions. Export named functions like `export async function score(args, ctx) {…}`, or remove the folder.");
2737
+ fns = hasServer ? discoverFnExports(serverEntry) : [];
2738
+ const datasets = hasDatasets ? discoverFnExports(datasetsEntry) : [];
2739
+ const dup = fns.filter((f) => datasets.includes(f));
2740
+ if (dup.length > 0) {
2741
+ fail("DUPLICATE_EXPORT", `"${dup[0]}" is exported from both server/index.ts and datasets/index.ts — one name, one home. Remove one of the two exports.`);
2742
+ }
2743
+ if (fns.length === 0 && datasets.length === 0) {
2744
+ fail("NO_FN_EXPORTS", "server/index.ts / datasets/index.ts exist but export no functions. Export named functions like `export async function score(args, ctx) {…}`, or remove the folder.");
2165
2745
  }
2166
- return { code: readFileSync(out, "utf8"), fns };
2746
+ return { code: readFileSync(out, "utf8"), fns, datasets };
2167
2747
  } catch (e) {
2168
- if (e?.code === "NO_FN_EXPORTS") throw e; // fail() already exited; guard for safety
2748
+ if (e?.code === "NO_FN_EXPORTS" || e?.code === "DUPLICATE_EXPORT") throw e; // fail() already exited; guard for safety
2169
2749
  const msg = e?.errors?.[0]?.text ?? e?.message ?? String(e);
2170
- fail("FN_BUNDLE_FAILED", `Could not bundle server/index.ts: ${msg}`);
2750
+ fail("FN_BUNDLE_FAILED", `Could not bundle the app's server code: ${msg}`);
2171
2751
  } finally {
2172
2752
  rmSync(entry, { force: true });
2173
2753
  rmSync(out, { force: true });
@@ -2191,10 +2771,12 @@ function discoverFnExports(serverEntry) {
2191
2771
  }
2192
2772
 
2193
2773
  // Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
2194
- // Studio heartbeat's last good schema through transient config breakage.
2774
+ // the session heartbeat's last good schema through transient config breakage.
2195
2775
  async function compileConfig(appDir, { soft = false } = {}) {
2196
2776
  try {
2197
- return await compileAppConfig(appDir);
2777
+ // Config-only apps (no SPA) always compile a manifest: the platform
2778
+ // shell is their only renderer.
2779
+ return await compileAppConfig(appDir, { forceManifest: isConfigOnlyApp(appDir) });
2198
2780
  } catch (e) {
2199
2781
  if (e instanceof CompileError) {
2200
2782
  if (soft) {
@@ -2228,7 +2810,7 @@ function walk(dir) {
2228
2810
  return out;
2229
2811
  }
2230
2812
 
2231
- // ── cron matching (the Studio ticker in `monty dev`) ──────────────────────
2813
+ // ── cron matching (the session's cron ticker in `monty dev`) ─────────────
2232
2814
  // UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
2233
2815
  // names (JAN, MON). Deliberately forgiving: an unparsable field simply never
2234
2816
  // matches locally — Cloudflare is the syntax authority at deploy, so a bad
@@ -2289,15 +2871,14 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
2289
2871
  // terminal — no browser, no dev session. Auth is the mk_ key exchanged at
2290
2872
  // /api/dev-token for a 5-minute workspace token (member lane, org_id from
2291
2873
  // the verified JWT), then the 6 public records functions over Convex's HTTP
2292
- // API. `app` picks the records namespace: the plain slug is Live; --studio
2293
- // targets "{slug}#dev" (the Studio sandbox same wire literal the dev
2294
- // shell uses). Results are ONE JSON document on stdout so agents can pipe.
2874
+ // API. `app` is the plain slug every app has ONE set of records.
2875
+ // Results are ONE JSON document on stdout so agents can pipe.
2295
2876
 
2296
2877
  const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
2297
2878
 
2298
2879
  // rest, minus flags AND their values — `monty data list leads --app crm`
2299
- // must not read "crm" as a positional. Boolean flags (--studio) have no
2300
- // value and are skipped alone.
2880
+ // must not read "crm" as a positional. Boolean flags have no value and
2881
+ // are skipped alone.
2301
2882
  function dataPositionals() {
2302
2883
  const out = [];
2303
2884
  for (let i = 0; i < rest.length; i++) {
@@ -2331,7 +2912,10 @@ function resolveDataApp() {
2331
2912
  if (!slug) {
2332
2913
  fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
2333
2914
  }
2334
- return rest.includes("--studio") ? `${slug}#dev` : slug;
2915
+ if (rest.includes("--studio")) {
2916
+ fail("STUDIO_REMOVED", "The session sandbox is gone — every app has one set of records, and data verbs always target it. Drop --studio.");
2917
+ }
2918
+ return slug;
2335
2919
  }
2336
2920
 
2337
2921
  // mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
@@ -2398,7 +2982,7 @@ function flattenRow(doc) {
2398
2982
  }
2399
2983
 
2400
2984
  function dataUsage() {
2401
- console.log("usage: monty data <verb> [table] [flags] read/write an app's Live records (add --studio for the Studio sandbox)");
2985
+ console.log("usage: monty data <verb> [table] [flags] read/write an app's records");
2402
2986
  console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
2403
2987
  console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
2404
2988
  console.log(" get <table> <id>");
@@ -2406,7 +2990,7 @@ function dataUsage() {
2406
2990
  console.log(" update <table> <id> --data '<json>' [--unset field,field]");
2407
2991
  console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
2408
2992
  console.log(" remove <table> <id>");
2409
- console.log("target: --app <slug> (or run inside the app folder); default is LIVE data — --studio targets the sandbox");
2993
+ console.log("target: --app <slug> (or run inside the app folder)");
2410
2994
  process.exit(1);
2411
2995
  }
2412
2996
 
@@ -2552,6 +3136,101 @@ if (command !== "dev" && command !== "logs") {
2552
3136
  installSkills({ appDir: findAppRoot(process.cwd()) });
2553
3137
  }
2554
3138
 
3139
+ // ── monty schema — the manifest door ──────────────────────────────────────
3140
+ // The app's data half (tables, field algebra, metrics, settings, pages)
3141
+ // lives ONLY in the workspace. `monty schema [slug]` prints the stored
3142
+ // manifest as JSON (and stamps the CAS base); edit that JSON and
3143
+ // `monty schema set <file|->` writes it back through the one landing —
3144
+ // validated server-side, additive-only by default, CAS against what you
3145
+ // read. `monty schema pull` (legacy) regenerates monty.config.ts.
3146
+ async function schemaCmd() {
3147
+ const verb = rest[0];
3148
+ const { host, key } = loadConfig() ?? {};
3149
+ if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
3150
+ const appDir = findAppRoot(process.cwd());
3151
+
3152
+ if (verb === "pull") {
3153
+ const dir = appDir ?? process.cwd();
3154
+ let slug = rest.slice(1).find((a) => !a.startsWith("--"));
3155
+ if (!slug) {
3156
+ try {
3157
+ slug = (await compileAppConfig(dir)).slug;
3158
+ } catch {
3159
+ fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
3160
+ }
3161
+ }
3162
+ await schemaPull({
3163
+ appDir: dir, host, key, slug,
3164
+ force: rest.includes("--force"),
3165
+ compileAppConfig,
3166
+ fail,
3167
+ });
3168
+ return;
3169
+ }
3170
+
3171
+ if (verb === "set") {
3172
+ const target = rest[1];
3173
+ if (!target) {
3174
+ fail("SCHEMA_USAGE", "Usage: monty schema set <file.json|-> [--allow-breaking] — the JSON is a full manifest (start from `monty schema`).");
3175
+ }
3176
+ let raw;
3177
+ try {
3178
+ raw = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
3179
+ } catch {
3180
+ fail("SCHEMA_USAGE", `Could not read ${target === "-" ? "stdin" : target}. Pass a manifest JSON file, or - for stdin.`);
3181
+ }
3182
+ let manifest;
3183
+ try {
3184
+ manifest = JSON.parse(raw);
3185
+ } catch {
3186
+ fail("BAD_MANIFEST_JSON", "That is not valid JSON. Start from `monty schema` output, edit, and set the whole document back.");
3187
+ }
3188
+ const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
3189
+ if (!slug) fail("INVALID_SLUG", "The manifest carries no slug and this is not an app folder — set `slug` in the JSON.");
3190
+ // CAS: prove which stored manifest this edit was based on (stamped by
3191
+ // the last `monty schema` read in this folder). Absent = trusting push.
3192
+ const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
3193
+ const res = await fetch(`${host}/api/schema`, {
3194
+ method: "POST",
3195
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
3196
+ body: JSON.stringify({
3197
+ slug,
3198
+ manifest,
3199
+ ...(base ? { baseHash: base } : {}),
3200
+ ...(rest.includes("--allow-breaking") ? { allowBreaking: true } : {}),
3201
+ }),
3202
+ });
3203
+ const body = await res.json().catch(() => null);
3204
+ if (!res.ok || !body?.ok) {
3205
+ if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
3206
+ console.log(`schema drift (remote changes):\n${body.summary}`);
3207
+ }
3208
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the manifest failed — is the Monty host reachable?");
3209
+ }
3210
+ if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3211
+ console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
3212
+ return;
3213
+ }
3214
+
3215
+ // Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
3216
+ // JSON (pipe it to a file, edit, `monty schema set` it back).
3217
+ const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
3218
+ if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
3219
+ const res = await fetch(`${host}/api/schema?slug=${slug}`, {
3220
+ headers: { authorization: `Bearer ${key}` },
3221
+ });
3222
+ const body = await res.json().catch(() => null);
3223
+ if (!res.ok || !body?.ok) {
3224
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the manifest — check the connection and `monty login`.");
3225
+ }
3226
+ if (body.manifest === null) {
3227
+ fail("NO_MANIFEST", `"${slug}" has no stored manifest yet — it's a code-only app. Declare one by setting a full manifest JSON: monty schema set <file>.`);
3228
+ }
3229
+ if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3230
+ console.error(`# ${slug} — manifest hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3231
+ console.log(JSON.stringify(body.manifest, null, 2));
3232
+ }
3233
+
2555
3234
  switch (command) {
2556
3235
  case "login":
2557
3236
  await login();
@@ -2563,7 +3242,7 @@ switch (command) {
2563
3242
  await pull();
2564
3243
  break;
2565
3244
  case "commit":
2566
- await commit();
3245
+ fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
2567
3246
  break;
2568
3247
  case "log":
2569
3248
  case "versions":
@@ -2595,7 +3274,7 @@ switch (command) {
2595
3274
  apps();
2596
3275
  break;
2597
3276
  case "skills":
2598
- installSkills({ appDir: findAppRoot(process.cwd()), silent: false });
3277
+ installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
2599
3278
  console.log("skills: up to date");
2600
3279
  break;
2601
3280
  case "install":
@@ -2607,25 +3286,28 @@ switch (command) {
2607
3286
  case "typecheck":
2608
3287
  typecheckApp();
2609
3288
  break;
3289
+ case "save":
2610
3290
  case "deploy":
2611
3291
  await deploy();
2612
3292
  break;
2613
3293
  case "data":
2614
3294
  await data();
2615
3295
  break;
3296
+ case "schema":
3297
+ await schemaCmd();
3298
+ break;
2616
3299
  case "secret":
2617
3300
  await secret();
2618
3301
  break;
2619
3302
  default:
2620
- console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|data|skills>");
3303
+ console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|skills>");
2621
3304
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
2622
- console.log(" create <slug> [--name N] [--icon I] [--build ID] register + stamp a new app into ~/.monty/apps/<id> (needs login)");
3305
+ console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
2623
3306
  console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
2624
- console.log(" commit [-m \"message\"] version the app's source in the workspace without publishing");
2625
- console.log(" log [slug] the app's source version history (commits + publishes)");
2626
- console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app in Studio, or attach to a running session (sandboxed data)");
2627
- console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, publish results)");
2628
- console.log(" add <name...> install curated UI components (see `monty components`)");
3307
+ console.log(" log [slug] the app's source version history (one row per save)");
3308
+ console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app's session, or attach to a running one (live data, auto-auth)");
3309
+ console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, save results)");
3310
+ console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
2629
3311
  console.log(" components [query] list the curated component catalog");
2630
3312
  console.log(" docs <name> view a component's source before installing");
2631
3313
  console.log(" current which app folder am I in?");
@@ -2634,8 +3316,10 @@ switch (command) {
2634
3316
  console.log(" install install app dependencies");
2635
3317
  console.log(" build production build (vite, via monty)");
2636
3318
  console.log(" typecheck typecheck (builds first if needed)");
2637
- console.log(" deploy build + upload this app straight to Live");
3319
+ console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main` (build + typecheck gate it)");
3320
+ console.log(" deploy alias of save");
2638
3321
  console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
3322
+ console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
2639
3323
  console.log(" skills install/refresh the agent build skill");
2640
3324
  process.exit(command ? 1 : 0);
2641
3325
  }