@montytools/cli 0.4.2 → 0.5.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
@@ -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
@@ -596,6 +598,63 @@ async function pull() {
596
598
  }
597
599
 
598
600
  // ── monty create ───────────────────────────────────────────────────────────
601
+ // A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
602
+ // the platform shell renders it. The presence of index.html is the marker
603
+ // (every SPA template ships one; the config-only scaffold never does).
604
+ function isConfigOnlyApp(appDir) {
605
+ return !existsSync(join(appDir, "index.html"));
606
+ }
607
+
608
+ const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
609
+
610
+ The entire app is \`monty.config.ts\`: tables (zod), derived fields
611
+ (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`, \`settings\`, and \`pages\`.
612
+ The Monty platform renders it — there is no src/, no React, no build.
613
+
614
+ - Edit monty.config.ts, save; a running \`monty dev\` pushes the change to the
615
+ Studio within seconds (watch the terminal for instruction-shaped errors).
616
+ - Formulas are strings in the Monty expression grammar, e.g.
617
+ \`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
618
+ ABOVE the formula and \`metrics.<name>\` are in scope.
619
+ - \`monty deploy\` publishes the config to the workspace (no bundle).
620
+ - Need a bespoke page later? \`monty add page\` upgrades this app with a SPA
621
+ scaffold; the config keeps working unchanged.
622
+ `;
623
+
624
+ function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
625
+ mkdirSync(target, { recursive: true });
626
+ writeFileSync(join(target, "monty.config.ts"), `import { defineApp } from "@montytools/sdk";
627
+
628
+ // This file IS the app: tables, derived fields, metrics, settings, pages.
629
+ // The Monty platform renders it — no src/, no build. Declare tables as zod
630
+ // objects; derive with rollup()/lookup()/formula(); see AGENTS.md.
631
+ export const app = defineApp({
632
+ id: "${appId}",
633
+ slug: "${slug}",
634
+ name: "${name}",
635
+ icon: "${icon}",
636
+ tables: {},
637
+ });
638
+
639
+ export type App = typeof app;
640
+ `);
641
+ writeFileSync(join(target, "package.json"), JSON.stringify({
642
+ name: slug,
643
+ private: true,
644
+ type: "module",
645
+ dependencies: { "@montytools/sdk": "latest", zod: "^4.4.3" },
646
+ }, null, 2) + "\n");
647
+ writeFileSync(join(target, "tsconfig.json"), JSON.stringify({
648
+ compilerOptions: {
649
+ target: "ES2022", module: "ESNext", moduleResolution: "bundler",
650
+ strict: true, skipLibCheck: true, noEmit: true,
651
+ },
652
+ include: ["monty.config.ts"],
653
+ }, null, 2) + "\n");
654
+ writeFileSync(join(target, ".gitignore"), "node_modules/\n.monty/\n");
655
+ writeFileSync(join(target, "AGENTS.md"), CONFIG_ONLY_AGENTS_MD);
656
+ }
657
+
599
658
  async function create() {
600
659
  const slug = rest.find((a) => !a.startsWith("--"));
601
660
  if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
@@ -652,6 +711,48 @@ async function create() {
652
711
  }
653
712
  mkdirSync(dirname(target), { recursive: true });
654
713
 
714
+ // DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
715
+ // platform shell renders it; \`monty add page\` scaffolds a SPA the moment a
716
+ // bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
717
+ // (\`--config-only\` stays accepted as the now-default no-op).
718
+ if (!rest.includes("--spa")) {
719
+ console.log(`create: ${slug} -> ${target} (config-only)`);
720
+ writeConfigOnlyScaffold(target, { appId, slug, name, icon });
721
+ // The user's brief lands at the top of AGENTS.md, same as SPA creates.
722
+ const brief = flag("description");
723
+ if (brief?.trim()) {
724
+ const agentsPath = join(target, "AGENTS.md");
725
+ writeFileSync(
726
+ agentsPath,
727
+ `# What to build: ${name}\n\n${brief.trim()}\n\n---\n\n` + readFileSync(agentsPath, "utf8"),
728
+ );
729
+ console.log("brief: AGENTS.md carries the app description");
730
+ }
731
+ const cBuildId = flag("build");
732
+ if (cBuildId && /^[a-z0-9]{10,64}$/i.test(cBuildId)) {
733
+ mkdirSync(join(target, ".monty"), { recursive: true });
734
+ writeFileSync(join(target, ".monty", "build"), cBuildId + "\n");
735
+ try {
736
+ await fetch(`${host}/api/build`, {
737
+ method: "POST",
738
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
739
+ body: JSON.stringify({ buildId: cBuildId, slug }),
740
+ });
741
+ } catch { /* progress signal only */ }
742
+ }
743
+ if (!existsSync(join(target, "CLAUDE.md"))) {
744
+ try {
745
+ symlinkSync("AGENTS.md", join(target, "CLAUDE.md"));
746
+ } catch {
747
+ writeFileSync(join(target, "CLAUDE.md"), "@AGENTS.md\n");
748
+ }
749
+ }
750
+ installSkills({ appDir: target });
751
+ console.log(`created: ${target}`);
752
+ console.log(`next: cd ${target} && monty install && monty dev`);
753
+ return;
754
+ }
755
+
655
756
  // Template is bundled into the published package (../template). Fall back to
656
757
  // the monorepo path when running the CLI in-place during development.
657
758
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -819,7 +920,7 @@ async function freePort(start) {
819
920
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
820
921
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
821
922
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
822
- const MIN_SDK = "0.1.5";
923
+ const MIN_SDK = "0.2.0";
823
924
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
824
925
 
825
926
  function installedSdkVersion(appDir) {
@@ -1282,22 +1383,29 @@ async function dev() {
1282
1383
  const meta = await compileConfig(appDir);
1283
1384
  const cfg = loadConfig();
1284
1385
  const host = cfg?.host ?? DEFAULT_HOST;
1386
+ // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
1387
+ // compile + push — the platform shell renders the Studio channel.
1388
+ const configOnly = isConfigOnlyApp(appDir);
1285
1389
  // Auto-pick a free port (agents run several apps side by side); an
1286
1390
  // explicit --port is honored strictly.
1287
1391
  const requested = flag("port");
1288
- const port = requested ? Number(requested) : await freePort(5173);
1392
+ const port = configOnly ? null : requested ? Number(requested) : await freePort(5173);
1289
1393
 
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.");
1394
+ let child = null;
1395
+ if (!configOnly) {
1396
+ const viteBin = resolveViteBin(appDir);
1397
+ if (!viteBin) {
1398
+ fail("VITE_MISSING", "vite is not installed in this app. Run `monty install`, then `monty dev` again.");
1399
+ }
1400
+ console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
1401
+ child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
1402
+ cwd: appDir,
1403
+ stdio: ["ignore", "pipe", "pipe"],
1404
+ });
1405
+ } else {
1406
+ console.log(`dev: config-only app "${meta.slug}" — no vite; watching monty.config.ts`);
1293
1407
  }
1294
1408
 
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
1409
  let tunnelChild = null;
1302
1410
  let pubChild = null;
1303
1411
  let hbTimer = null;
@@ -1335,10 +1443,10 @@ async function dev() {
1335
1443
  sessionId,
1336
1444
  state: "starting",
1337
1445
  loggedIn,
1338
- appUrl: `http://localhost:${port}`,
1446
+ appUrl: configOnly ? null : `http://localhost:${port}`,
1339
1447
  tunnelUrl: null,
1340
1448
  studioUrl: loggedIn ? `${host}/studio/${meta.slug}` : null,
1341
- previewUrl: loggedIn
1449
+ previewUrl: loggedIn && !configOnly
1342
1450
  ? `${host}/studio/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1343
1451
  : null,
1344
1452
  publishing: false,
@@ -1421,7 +1529,7 @@ async function dev() {
1421
1529
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1422
1530
  // vite is a direct child (no npx wrapper), so this actually kills it —
1423
1531
  // a bare SIGTERM from the desktop must never orphan vite on the port.
1424
- try { child.kill(); } catch { /* already gone */ }
1532
+ try { child?.kill(); } catch { /* already gone */ }
1425
1533
  sf.remove();
1426
1534
  logSink.close();
1427
1535
  await clearDevSession();
@@ -1435,7 +1543,7 @@ async function dev() {
1435
1543
  if (cronTimer) clearInterval(cronTimer);
1436
1544
  try { pubChild?.kill(); } catch { /* already gone */ }
1437
1545
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1438
- try { child.kill(); } catch { /* already gone */ }
1546
+ try { child?.kill(); } catch { /* already gone */ }
1439
1547
  console.log(`dev-session: superseded — ${fix}`);
1440
1548
  sf.remove(); // guarded — never deletes the new owner's file
1441
1549
  logSink.close();
@@ -1455,7 +1563,7 @@ async function dev() {
1455
1563
  headers: { authorization: `Bearer ${liveKey}`, "content-type": "application/json" },
1456
1564
  body: JSON.stringify({
1457
1565
  slug: meta.slug,
1458
- tunnelUrl: originUrl,
1566
+ ...(originUrl !== undefined ? { tunnelUrl: originUrl } : {}),
1459
1567
  sessionId,
1460
1568
  // Keep claiming until the first successful registration, but only
1461
1569
  // within the lock's own 90s TTL window: after a takeover/crash the
@@ -1468,6 +1576,12 @@ async function dev() {
1468
1576
  icon: currentMeta.icon,
1469
1577
  buildId,
1470
1578
  schemaJson: currentMeta.schemaJson,
1579
+ // App Manifest v2 (docs/manifest-v2.md) — present only for V2
1580
+ // configs; the host forwards it to devManifestJson (Stage 3 wiring).
1581
+ manifest: currentMeta.manifest,
1582
+ // The CAS base for the Studio channel (see `monty schema pull`).
1583
+ baseManifestHash:
1584
+ currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
1471
1585
  // The expose block rides the same compile as the schema — the dev
1472
1586
  // visitor preview is gated on it (devExposureJson).
1473
1587
  exposure: currentMeta.exposure,
@@ -1480,6 +1594,11 @@ async function dev() {
1480
1594
  stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
1481
1595
  return false;
1482
1596
  }
1597
+ if (data?.code === "MANIFEST_DRIFT") {
1598
+ // Remote schema edits (another agent, via the API) — the terminal
1599
+ // the building agent is watching gets the fix, invariant #4.
1600
+ console.log(`schema drift (remote changes):${data.summary ? `\n${data.summary}` : ""}`);
1601
+ }
1483
1602
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
1484
1603
  // A dead key is a SIGNED-OUT session — advertise it so the desktop
1485
1604
  // (which owns the session) can surface sign-in instead of letting
@@ -1489,6 +1608,11 @@ async function dev() {
1489
1608
  }
1490
1609
  return false;
1491
1610
  }
1611
+ if (currentMeta.manifest !== undefined) {
1612
+ // This beat's manifest is now the stored Studio manifest — the new
1613
+ // CAS base for both channels' future pushes.
1614
+ try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
1615
+ }
1492
1616
  if (!registeredOnce) {
1493
1617
  registeredOnce = true;
1494
1618
  sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
@@ -1540,7 +1664,8 @@ async function dev() {
1540
1664
  return;
1541
1665
  }
1542
1666
  await clearDevSession();
1543
- let originUrl = `http://localhost:${port}`;
1667
+ // Config-only sessions register WITHOUT an origin — nothing to iframe.
1668
+ let originUrl = configOnly ? undefined : `http://localhost:${port}`;
1544
1669
  let tunnelUpdate = Promise.resolve();
1545
1670
  let tunnelVersion = 0;
1546
1671
  async function activateTunnelUrl(url, { initial = false } = {}) {
@@ -1579,7 +1704,7 @@ async function dev() {
1579
1704
  console.log("tunnel: URL update failed — waiting for the next tunnel URL");
1580
1705
  });
1581
1706
  };
1582
- if (!rest.includes("--no-tunnel")) {
1707
+ if (!configOnly && !rest.includes("--no-tunnel")) {
1583
1708
  console.log("tunnel: starting (cloudflared quick tunnel)…");
1584
1709
  // cloudflared output is teed to dev.log only (the terminal stays quiet,
1585
1710
  // exactly as today) — post-mortems get the tunnel noise.
@@ -1603,10 +1728,28 @@ async function dev() {
1603
1728
  hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
1604
1729
  }
1605
1730
 
1731
+ if (configOnly) {
1732
+ sf.write({ state: "ready" });
1733
+ console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
1734
+ console.log("ready: config-only — the Studio renders this app; edits push on save");
1735
+ void startDevSession();
1736
+ // Config edits should reach the Studio in seconds, not a heartbeat:
1737
+ // watch the mtime and trigger an early beat (which recompiles + pushes).
1738
+ const cfgWatch = setInterval(() => {
1739
+ try {
1740
+ if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
1741
+ } catch { /* transient fs hiccup */ }
1742
+ }, 2000);
1743
+ const stopWatch = () => clearInterval(cfgWatch);
1744
+ process.on("SIGINT", stopWatch);
1745
+ process.on("SIGTERM", stopWatch);
1746
+ process.on("SIGHUP", stopWatch);
1747
+ }
1748
+
1606
1749
  let announced = false;
1607
1750
  const viteOutTee = logSink.source();
1608
1751
  const viteErrTee = logSink.source();
1609
- child.stdout.on("data", (chunk) => {
1752
+ child?.stdout.on("data", (chunk) => {
1610
1753
  const text = chunk.toString();
1611
1754
  process.stdout.write(text);
1612
1755
  viteOutTee(chunk);
@@ -1623,16 +1766,16 @@ async function dev() {
1623
1766
  });
1624
1767
  // vite stderr is where build errors and the SDK's browser-error mirror
1625
1768
  // land — piped (was inherit) so `monty logs` sees them too.
1626
- child.stderr.on("data", (chunk) => {
1769
+ child?.stderr.on("data", (chunk) => {
1627
1770
  process.stderr.write(chunk);
1628
1771
  viteErrTee(chunk);
1629
1772
  });
1630
- child.on("error", (e) => {
1773
+ child?.on("error", (e) => {
1631
1774
  void endSession().then(() => {
1632
1775
  fail("VITE_SPAWN_FAILED", `Could not start vite: ${e?.message ?? e}. Run \`monty install\`, then retry.`);
1633
1776
  });
1634
1777
  });
1635
- child.on("exit", (code) => {
1778
+ child?.on("exit", (code) => {
1636
1779
  viteOutTee.flush();
1637
1780
  viteErrTee.flush();
1638
1781
  void endSession().then(() => process.exit(code ?? 0));
@@ -1881,11 +2024,154 @@ function resolveComponent(name) {
1881
2024
  return name;
1882
2025
  }
1883
2026
 
2027
+ // ── monty add page <name> ──────────────────────────────────────────────────
2028
+ // Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
2029
+ // use (config-only apps gain src/ + vite from the template — their
2030
+ // monty.config.ts and AGENTS.md stay untouched), writes the page route, and
2031
+ // registers `pages.<name> = { kind: "custom", path: "/<name>" }` so the
2032
+ // platform shell mounts it. The Shopify model: system pages stay
2033
+ // shell-rendered; only this page is the app's own code.
2034
+ async function addPage(appDir, pageName) {
2035
+ if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
2036
+ fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2037
+ }
2038
+ const configPath = join(appDir, "monty.config.ts");
2039
+ if (!existsSync(configPath)) {
2040
+ fail("NO_CONFIG", `No monty.config.ts in ${appDir} — run this inside a Monty app.`);
2041
+ }
2042
+ const config = readFileSync(configPath, "utf8");
2043
+ const appName = config.match(/name: "([^"]*)"/)?.[1] ?? pageName;
2044
+ const routeFile = join(appDir, "src", "routes", `${pageName}.tsx`);
2045
+ if (existsSync(routeFile)) {
2046
+ fail("PAGE_EXISTS", `src/routes/${pageName}.tsx already exists. Edit it, or pick a different page name.`);
2047
+ }
2048
+ if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
2049
+ console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
2050
+ }
2051
+
2052
+ // First custom page on a config-only app: bring in the SPA scaffold.
2053
+ if (isConfigOnlyApp(appDir)) {
2054
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
2055
+ const templateDir = [
2056
+ join(pkgRoot, "template"),
2057
+ join(pkgRoot, "..", "template"),
2058
+ ].find((d) => existsSync(join(d, "monty.config.ts")));
2059
+ if (!templateDir) {
2060
+ fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
2061
+ }
2062
+ console.log(`add page: upgrading config-only app with the SPA scaffold`);
2063
+ cpSync(templateDir, appDir, {
2064
+ recursive: true,
2065
+ force: false, // existing files (config, AGENTS.md, package.json…) win
2066
+ filter: (src) => {
2067
+ const base = basename(src);
2068
+ if (["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base)) return false;
2069
+ // The app keeps its own identity files.
2070
+ if (["monty.config.ts", "AGENTS.md", "CLAUDE.md"].includes(base)) return false;
2071
+ return true;
2072
+ },
2073
+ });
2074
+ // gitignore ships name-mangled in the published bundle (see create()).
2075
+ if (existsSync(join(appDir, "gitignore")) && !existsSync(join(appDir, ".gitignore"))) {
2076
+ renameSync(join(appDir, "gitignore"), join(appDir, ".gitignore"));
2077
+ }
2078
+ // Merge the template's SPA deps/scripts into the app's package.json —
2079
+ // the config-only one has only sdk+zod and no scripts.
2080
+ const tplPkg = JSON.parse(readFileSync(join(templateDir, "package.json"), "utf8"));
2081
+ const pkgPath = join(appDir, "package.json");
2082
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
2083
+ pkg.scripts = { ...tplPkg.scripts, ...pkg.scripts };
2084
+ pkg.dependencies = { ...tplPkg.dependencies, ...pkg.dependencies };
2085
+ pkg.devDependencies = { ...tplPkg.devDependencies, ...pkg.devDependencies };
2086
+ // The published template pins the sdk; a workspace app may carry
2087
+ // workspace:* — the merge above keeps the app's existing pin either way.
2088
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
2089
+ const htmlPath = join(appDir, "index.html");
2090
+ if (existsSync(htmlPath)) {
2091
+ writeFileSync(htmlPath, readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${appName}</title>`));
2092
+ }
2093
+ // Client env for the SPA half, same as create().
2094
+ if (!existsSync(join(appDir, ".env.local"))) {
2095
+ try {
2096
+ const { host } = loadConfig();
2097
+ const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
2098
+ writeFileSync(join(appDir, ".env.local"), `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`);
2099
+ console.log("config: .env.local written");
2100
+ } catch {
2101
+ console.log("config: WARNING — could not fetch client config; copy .env.example to .env.local manually");
2102
+ }
2103
+ }
2104
+ // The template's starter index route assumes it IS the app — for a page
2105
+ // upgrade the shell owns the app; drop the starter so the page below is
2106
+ // the only route beside __root.
2107
+ const starter = join(appDir, "src", "routes", "index.tsx");
2108
+ if (existsSync(starter)) rmSync(starter);
2109
+ appendFileSync(
2110
+ join(appDir, "AGENTS.md"),
2111
+ `\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>" }\` entry in\nmonty.config.ts. System pages (views, dashboards) stay config-rendered —\nonly build bespoke UI here. \`monty dev\` serves both; \`monty deploy\`\npublishes both.\n`,
2112
+ );
2113
+ }
2114
+
2115
+ // The page route: a real, working start — SDK data hooks, shell-aware.
2116
+ mkdirSync(dirname(routeFile), { recursive: true });
2117
+ writeFileSync(routeFile, `import { createFileRoute } from "@tanstack/react-router";
2118
+
2119
+ export const Route = createFileRoute("/${pageName}")({
2120
+ component: ${pageComponentName(pageName)},
2121
+ });
2122
+
2123
+ // A CUSTOM page: bespoke UI mounted inside the platform shell at
2124
+ // /apps/<slug>/${pageName}. Data comes from @montytools/sdk hooks
2125
+ // (useList/useInsert/…) against the same tables the shell renders.
2126
+ function ${pageComponentName(pageName)}() {
2127
+ return (
2128
+ <main className="p-6">
2129
+ <h1 className="text-lg font-semibold">${appName} — ${pageName}</h1>
2130
+ <p className="mt-2 text-sm text-muted-foreground">
2131
+ Build this page. It ships with the app on the next \`monty deploy\`.
2132
+ </p>
2133
+ </main>
2134
+ );
2135
+ }
2136
+ `);
2137
+ console.log(`page: src/routes/${pageName}.tsx`);
2138
+
2139
+ // Register the page in the config's pages block (insert or create).
2140
+ const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
2141
+ let next = null;
2142
+ if (/^(\s*)pages:\s*{/m.test(config)) {
2143
+ next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
2144
+ } else {
2145
+ // No pages block: add one right before the config's closing `});`.
2146
+ const close = config.lastIndexOf("});");
2147
+ if (close !== -1) {
2148
+ next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
2149
+ }
2150
+ }
2151
+ if (next) {
2152
+ writeFileSync(configPath, next);
2153
+ console.log(`config: pages.${pageName} registered in monty.config.ts`);
2154
+ } else {
2155
+ console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
2156
+ }
2157
+
2158
+ console.log(`added: custom page "${pageName}"`);
2159
+ console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty deploy\` publishes it.`);
2160
+ }
2161
+
2162
+ function pageComponentName(pageName) {
2163
+ return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
2164
+ }
2165
+
1884
2166
  async function add() {
1885
2167
  const appDir = requireAppDir("add");
1886
2168
  const names = rest.filter((a) => !a.startsWith("--"));
1887
2169
  if (names.length === 0) {
1888
- fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available.");
2170
+ fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available (or `monty add page <name>` for a custom page).");
2171
+ }
2172
+ if (names[0] === "page") {
2173
+ await addPage(appDir, names[1]);
2174
+ return;
1889
2175
  }
1890
2176
  const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
1891
2177
 
@@ -2015,16 +2301,24 @@ async function deploy() {
2015
2301
  const meta = await compileConfig(appDir);
2016
2302
  console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
2017
2303
 
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.");
2304
+ // CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle
2305
+ // the platform shell renders the app.
2306
+ const configOnly = isConfigOnlyApp(appDir);
2307
+ if (!configOnly) {
2308
+ // 2) Fail fast locally before any upload. Build FIRST — it also generates
2309
+ // src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
2310
+ run(appDir, "build", ["npx", "vite", "build"],
2311
+ "The production build failed. Read the vite error above; it names the file to fix.");
2312
+ run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
2313
+ "TypeScript errors above. Fix them in the listed files; `monty deploy` never uploads code that does not compile.");
2314
+ } else if (!meta.manifest) {
2315
+ fail("MANIFEST_MISSING",
2316
+ "This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
2317
+ }
2024
2318
 
2025
2319
  // 3) Multipart POST to the host.
2026
2320
  const dist = join(appDir, "dist");
2027
- const files = walk(dist);
2321
+ const files = configOnly ? [] : walk(dist);
2028
2322
  const buildFile = join(appDir, ".monty", "build");
2029
2323
  if (existsSync(buildFile)) {
2030
2324
  meta.buildId = readFileSync(buildFile, "utf8").trim();
@@ -2082,6 +2376,13 @@ async function deploy() {
2082
2376
  console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this publish (restore anywhere: monty pull ${meta.slug})`);
2083
2377
  }
2084
2378
  }
2379
+ // V2 schema CAS: prove which stored manifest this checkout last synced,
2380
+ // so an API/MCP edit made meanwhile surfaces as MANIFEST_DRIFT instead of
2381
+ // being clobbered (fix: `monty schema pull`).
2382
+ if (meta.manifest !== undefined) {
2383
+ const state = readSchemaState(appDir);
2384
+ if (state?.hash) meta.baseManifestHash = state.hash;
2385
+ }
2085
2386
  form.set("monty", JSON.stringify(meta));
2086
2387
  let total = 0;
2087
2388
  for (const file of files) {
@@ -2090,7 +2391,11 @@ async function deploy() {
2090
2391
  total += buf.byteLength;
2091
2392
  form.set(rel, new Blob([buf]), rel);
2092
2393
  }
2093
- console.log(`upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`);
2394
+ console.log(
2395
+ configOnly
2396
+ ? `upload: config-only (manifest, no bundle) -> ${config.host}/api/deploy`
2397
+ : `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
2398
+ );
2094
2399
  const res = await fetch(`${config.host}/api/deploy`, {
2095
2400
  method: "POST",
2096
2401
  headers: { authorization: `Bearer ${config.key}` },
@@ -2098,10 +2403,17 @@ async function deploy() {
2098
2403
  });
2099
2404
  const body = await res.json().catch(() => null);
2100
2405
  if (!res.ok || !body?.ok) {
2406
+ if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
2407
+ console.log(`schema drift (remote changes):\n${body.summary}`);
2408
+ }
2101
2409
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
2102
2410
  }
2103
2411
  console.log(`origin: ${body.origin}`);
2104
2412
  console.log(`deployed: ${body.url} (version ${body.version})`);
2413
+ // The manifest just published IS the new CAS base.
2414
+ if (meta.manifest !== undefined) {
2415
+ writeSchemaState(appDir, manifestHash(meta.manifest));
2416
+ }
2105
2417
  // Stamp what was published — pull uses this to tell "unchanged since last
2106
2418
  // sync" from "locally modified".
2107
2419
  if (sourceHash) {
@@ -2194,7 +2506,9 @@ function discoverFnExports(serverEntry) {
2194
2506
  // Studio heartbeat's last good schema through transient config breakage.
2195
2507
  async function compileConfig(appDir, { soft = false } = {}) {
2196
2508
  try {
2197
- return await compileAppConfig(appDir);
2509
+ // Config-only apps (no SPA) always compile a manifest: the platform
2510
+ // shell is their only renderer.
2511
+ return await compileAppConfig(appDir, { forceManifest: isConfigOnlyApp(appDir) });
2198
2512
  } catch (e) {
2199
2513
  if (e instanceof CompileError) {
2200
2514
  if (soft) {
@@ -2552,6 +2866,36 @@ if (command !== "dev" && command !== "logs") {
2552
2866
  installSkills({ appDir: findAppRoot(process.cwd()) });
2553
2867
  }
2554
2868
 
2869
+ // ── monty schema — the schema-as-data door of the CLI ─────────────────────
2870
+ // `monty schema pull [slug] [--force]`: regenerate monty.config.ts from the
2871
+ // app's stored Live manifest (after another agent edited it via the API).
2872
+ // Distinct from `monty pull`, which restores the whole source snapshot.
2873
+ async function schemaCmd() {
2874
+ const verb = rest[0];
2875
+ if (verb !== "pull") {
2876
+ console.log("usage: monty schema pull [slug] [--force]");
2877
+ console.log(" pull regenerate monty.config.ts from the app's stored manifest (.bak kept; --force discards local schema edits)");
2878
+ process.exit(verb ? 1 : 0);
2879
+ }
2880
+ const { host, key } = loadConfig() ?? {};
2881
+ if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
2882
+ const appDir = findAppRoot(process.cwd()) ?? process.cwd();
2883
+ let slug = rest.slice(1).find((a) => !a.startsWith("--"));
2884
+ if (!slug) {
2885
+ try {
2886
+ slug = (await compileAppConfig(appDir)).slug;
2887
+ } catch {
2888
+ fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
2889
+ }
2890
+ }
2891
+ await schemaPull({
2892
+ appDir, host, key, slug,
2893
+ force: rest.includes("--force"),
2894
+ compileAppConfig,
2895
+ fail,
2896
+ });
2897
+ }
2898
+
2555
2899
  switch (command) {
2556
2900
  case "login":
2557
2901
  await login();
@@ -2613,19 +2957,22 @@ switch (command) {
2613
2957
  case "data":
2614
2958
  await data();
2615
2959
  break;
2960
+ case "schema":
2961
+ await schemaCmd();
2962
+ break;
2616
2963
  case "secret":
2617
2964
  await secret();
2618
2965
  break;
2619
2966
  default:
2620
2967
  console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|data|skills>");
2621
2968
  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)");
2969
+ console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
2623
2970
  console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
2624
2971
  console.log(" commit [-m \"message\"] version the app's source in the workspace without publishing");
2625
2972
  console.log(" log [slug] the app's source version history (commits + publishes)");
2626
2973
  console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app in Studio, or attach to a running session (sandboxed data)");
2627
2974
  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`)");
2975
+ console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
2629
2976
  console.log(" components [query] list the curated component catalog");
2630
2977
  console.log(" docs <name> view a component's source before installing");
2631
2978
  console.log(" current which app folder am I in?");
@@ -2636,6 +2983,7 @@ switch (command) {
2636
2983
  console.log(" typecheck typecheck (builds first if needed)");
2637
2984
  console.log(" deploy build + upload this app straight to Live");
2638
2985
  console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
2986
+ console.log(" schema pull [slug] [--force] regenerate monty.config.ts from the app's stored manifest (schema-as-data)");
2639
2987
  console.log(" skills install/refresh the agent build skill");
2640
2988
  process.exit(command ? 1 : 0);
2641
2989
  }
package/lib/compile.mjs CHANGED
@@ -19,7 +19,7 @@ export class CompileError extends Error {
19
19
  // Throws CompileError:
20
20
  // - CONFIG_BUNDLE_FAILED — esbuild could not bundle (usually unlinked deps)
21
21
  // - CONFIG_COMPILE_FAILED — the config threw while loading
22
- export async function compileAppConfig(appDir) {
22
+ export async function compileAppConfig(appDir, { forceManifest = false } = {}) {
23
23
  appDir = resolve(appDir); // esbuild requires an absolute absWorkingDir
24
24
  // esbuild is a dependency of THIS package (@montytools/cli), so resolution
25
25
  // from here works for any caller — no per-script resolution dance.
@@ -31,7 +31,7 @@ export async function compileAppConfig(appDir) {
31
31
  writeFileSync(entry, [
32
32
  `import { app } from "../monty.config";`,
33
33
  `import { compileApp } from "@montytools/sdk/compile";`,
34
- `process.stdout.write(JSON.stringify(compileApp(app)));`,
34
+ `process.stdout.write(JSON.stringify(compileApp(app, { forceManifest: ${forceManifest} })));`,
35
35
  ].join("\n"));
36
36
  try {
37
37
  await build({
@@ -0,0 +1,196 @@
1
+ // Manifest → monty.config.ts codegen + the canonical manifest hash.
2
+ //
3
+ // `monty schema pull` regenerates the whole config from the app's stored
4
+ // App Manifest v2. Regeneration is LOSSLESS for config-only apps because
5
+ // every V2 surface — including formulas, which are expression STRINGS — is
6
+ // data in the manifest; there is no code in a config that the manifest
7
+ // doesn't carry. The emitted file uses the same SDK constructors an agent
8
+ // would write by hand, so pulled configs and authored configs are the same
9
+ // dialect.
10
+ //
11
+ // The hash mirrors packages/backend/convex/lib/manifestValidate.ts exactly:
12
+ // sha256 over a SORTED-KEY stringify (representation-independent — Convex
13
+ // does not guarantee object key order; declaration order travels in the
14
+ // explicit `order` arrays).
15
+
16
+ import { createHash } from "node:crypto";
17
+
18
+ export function stableStringify(v) {
19
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
20
+ if (typeof v === "object" && v !== null) {
21
+ const keys = Object.keys(v).sort();
22
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(",")}}`;
23
+ }
24
+ return JSON.stringify(v) ?? "null";
25
+ }
26
+
27
+ export function manifestHash(manifest) {
28
+ return createHash("sha256").update(stableStringify(manifest)).digest("hex");
29
+ }
30
+
31
+ // ── zod/SDK source emission ────────────────────────────────────────────────
32
+
33
+ const IMPORTABLE = [
34
+ "defineApp", "formula", "lookup", "montyDate", "montyFileSchema",
35
+ "montyMember", "montyMoney", "montyPercent", "montyRef", "rollup", "self",
36
+ ];
37
+
38
+ function fieldsInOrder(section) {
39
+ const order = Array.isArray(section.order) ? section.order : Object.keys(section.fields);
40
+ return order.map((name) => [name, section.fields[name]]);
41
+ }
42
+
43
+ function storedSource(spec, used) {
44
+ let src;
45
+ switch (spec.type) {
46
+ case "string": src = "z.string()"; break;
47
+ case "number": src = "z.number()"; break;
48
+ case "boolean": src = "z.boolean()"; break;
49
+ case "money": used.add("montyMoney"); src = "montyMoney()"; break;
50
+ case "percent": used.add("montyPercent"); src = "montyPercent()"; break;
51
+ case "date": used.add("montyDate"); src = "montyDate()"; break;
52
+ case "member": used.add("montyMember"); src = "montyMember()"; break;
53
+ case "file": used.add("montyFileSchema"); src = "montyFileSchema"; break;
54
+ case "ref":
55
+ used.add("montyRef");
56
+ src = `montyRef(${JSON.stringify(spec.table)})`;
57
+ break;
58
+ case "enum":
59
+ src = `z.enum(${JSON.stringify(spec.values)})`;
60
+ break;
61
+ default:
62
+ // json: the manifest carries no deep shape (schemaJson owns storage
63
+ // validation) — z.any() keeps the field writable; refine by hand.
64
+ src = "z.any()";
65
+ }
66
+ if (spec.optional) src += ".optional()";
67
+ return src;
68
+ }
69
+
70
+ function outputSource(type, used) {
71
+ switch (type) {
72
+ case "money": used.add("montyMoney"); return "montyMoney()";
73
+ case "percent": used.add("montyPercent"); return "montyPercent()";
74
+ case "date": used.add("montyDate"); return "montyDate()";
75
+ case "member": used.add("montyMember"); return "montyMember()";
76
+ case "number": return "z.number()";
77
+ case "string": return "z.string()";
78
+ case "boolean": return "z.boolean()";
79
+ default: return "z.string()";
80
+ }
81
+ }
82
+
83
+ function rollupSource(spec, used, indent) {
84
+ used.add("rollup");
85
+ const pad = " ".repeat(indent);
86
+ const lines = [`from: ${JSON.stringify(spec.from)},`];
87
+ if (spec.where !== undefined) {
88
+ const entries = Object.entries(spec.where).map(([k, v]) => {
89
+ if (typeof v === "object" && v !== null && typeof v.self === "string") {
90
+ used.add("self");
91
+ return `${JSON.stringify(k)}: self(${JSON.stringify(v.self)})`;
92
+ }
93
+ return `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
94
+ });
95
+ lines.push(`where: { ${entries.join(", ")} },`);
96
+ }
97
+ if (spec.op === "sum") lines.push(`sum: ${JSON.stringify(spec.sumField)},`);
98
+ else lines.push("count: true,");
99
+ if (spec.over !== undefined) lines.push(`over: ${JSON.stringify(spec.over)},`);
100
+ if (spec.range !== undefined) lines.push(`range: ${JSON.stringify(spec.range)},`);
101
+ const body = lines.map((l) => `${pad} ${l}`).join("\n");
102
+ return `rollup(${outputSource(spec.output, used)}, {\n${body}\n${pad}})`;
103
+ }
104
+
105
+ function fieldSource(spec, used, indent) {
106
+ switch (spec.kind) {
107
+ case "stored":
108
+ return storedSource(spec, used);
109
+ case "formula":
110
+ used.add("formula");
111
+ return `formula(${outputSource(spec.output, used)}, ${JSON.stringify(spec.expr)})`;
112
+ case "lookup":
113
+ used.add("lookup");
114
+ return `lookup(${outputSource(spec.output, used)}, { ref: ${JSON.stringify(spec.ref)}, field: ${JSON.stringify(spec.field)} })`;
115
+ case "rollup":
116
+ return rollupSource(spec, used, indent);
117
+ default:
118
+ return "z.any()";
119
+ }
120
+ }
121
+
122
+ function settingsFieldSource(spec, defaultValue, used) {
123
+ let src = storedSource({ ...spec, optional: undefined }, used);
124
+ if (defaultValue !== undefined) src += `.default(${JSON.stringify(defaultValue)})`;
125
+ else if (spec.optional) src += ".optional()";
126
+ return src;
127
+ }
128
+
129
+ /** The generated monty.config.ts source for a manifest document. */
130
+ export function manifestToConfig(manifest, { name, icon } = {}) {
131
+ const used = new Set(["defineApp"]);
132
+ const out = [];
133
+
134
+ out.push(" tables: {");
135
+ for (const [tableName, table] of Object.entries(manifest.tables)) {
136
+ out.push(` ${JSON.stringify(tableName)}: z.object({`);
137
+ for (const [field, spec] of fieldsInOrder(table)) {
138
+ out.push(` ${JSON.stringify(field)}: ${fieldSource(spec, used, 6)},`);
139
+ }
140
+ out.push(" }),");
141
+ }
142
+ out.push(" },");
143
+
144
+ if (manifest.metrics !== undefined) {
145
+ out.push(" metrics: {");
146
+ for (const [name2, spec] of Object.entries(manifest.metrics)) {
147
+ out.push(` ${JSON.stringify(name2)}: ${rollupSource(spec, used, 4)},`);
148
+ }
149
+ out.push(" },");
150
+ }
151
+
152
+ if (manifest.settings !== undefined) {
153
+ out.push(" settings: z.object({");
154
+ const defaults = manifest.settings.defaults ?? {};
155
+ for (const [field, spec] of fieldsInOrder(manifest.settings)) {
156
+ out.push(` ${JSON.stringify(field)}: ${settingsFieldSource(spec, defaults[field], used)},`);
157
+ }
158
+ out.push(" }),");
159
+ }
160
+
161
+ if (manifest.pages !== undefined) {
162
+ out.push(` pages: ${JSON.stringify(manifest.pages, null, 2).replace(/\n/g, "\n ")},`);
163
+ }
164
+
165
+ if (manifest.datasets !== undefined) {
166
+ out.push(" datasets: {");
167
+ for (const [name2, ds] of Object.entries(manifest.datasets)) {
168
+ out.push(` ${JSON.stringify(name2)}: z.object({`);
169
+ for (const [field, spec] of fieldsInOrder(ds)) {
170
+ out.push(` ${JSON.stringify(field)}: ${fieldSource(spec, used, 6)},`);
171
+ }
172
+ out.push(" }),");
173
+ }
174
+ out.push(" },");
175
+ }
176
+
177
+ const sdkImports = IMPORTABLE.filter((n) => used.has(n));
178
+ return [
179
+ `import { z } from "zod";`,
180
+ `import {`,
181
+ ...sdkImports.map((n) => ` ${n},`),
182
+ `} from "@montytools/sdk";`,
183
+ ``,
184
+ `// Regenerated by \`monty schema pull\` from the app's stored manifest.`,
185
+ `// Edit freely — \`monty dev\` / \`monty deploy\` push changes back; another`,
186
+ `// editor's remote changes surface as MANIFEST_DRIFT (then pull again).`,
187
+ `export const app = defineApp({`,
188
+ ` slug: ${JSON.stringify(manifest.slug)},`,
189
+ ...(name ? [` name: ${JSON.stringify(name)},`] : []),
190
+ ...(icon ? [` icon: ${JSON.stringify(icon)},`] : []),
191
+ ...out,
192
+ `});`,
193
+ `export type App = typeof app;`,
194
+ ``,
195
+ ].join("\n");
196
+ }
@@ -0,0 +1,83 @@
1
+ // `monty schema pull` — regenerate monty.config.ts from the app's stored
2
+ // Live manifest (the schema-as-data flow: another agent may have edited the
3
+ // schema via the API/MCP; this brings the code checkout back in sync).
4
+ // Distinct from `monty pull`, which restores the whole SOURCE SNAPSHOT.
5
+ //
6
+ // Safety: refuses when the local config has schema changes that never
7
+ // reached the registry (compile-and-compare against the base hash recorded
8
+ // in .monty/schema.json) — "deploy or discard", like git with a dirty tree.
9
+ // The old file is backed up beside the new one on every overwrite.
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { manifestHash, manifestToConfig } from "./schemaCodegen.mjs";
14
+
15
+ const STATE_FILE = ["schema.json"]; // .monty/schema.json
16
+
17
+ export function readSchemaState(appDir) {
18
+ try {
19
+ return JSON.parse(readFileSync(join(appDir, ".monty", ...STATE_FILE), "utf8"));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /** Record the hash of the manifest this checkout last synced with the
26
+ * registry (written after every successful push and every pull — the CAS
27
+ * base for the next push). */
28
+ export function writeSchemaState(appDir, hash) {
29
+ const dir = join(appDir, ".monty");
30
+ mkdirSync(dir, { recursive: true });
31
+ writeFileSync(join(dir, ...STATE_FILE), JSON.stringify({ hash, syncedAt: Date.now() }) + "\n");
32
+ }
33
+
34
+ export async function schemaPull({ appDir, host, key, slug, force, compileAppConfig, fail }) {
35
+ const res = await fetch(`${host}/api/schema?slug=${encodeURIComponent(slug)}`, {
36
+ headers: { authorization: `Bearer ${key}` },
37
+ });
38
+ const body = await res.json().catch(() => null);
39
+ if (!res.ok || !body?.ok) {
40
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not fetch the app's schema — check the connection and `monty login`.");
41
+ }
42
+ if (!body.manifest) {
43
+ fail(
44
+ "NO_MANIFEST",
45
+ `"${slug}" has no stored App Manifest (it is a V1 app or has never pushed one). Author monty.config.ts with V2 features and run \`monty dev\` or \`monty deploy\` first.`,
46
+ );
47
+ }
48
+
49
+ const configPath = join(appDir, "monty.config.ts");
50
+ if (existsSync(configPath) && !force) {
51
+ // Dirty check: does the local config compile to the manifest this
52
+ // checkout last synced? If not, pulling would clobber local edits.
53
+ const state = readSchemaState(appDir);
54
+ let localHash = null;
55
+ try {
56
+ const compiled = await compileAppConfig(appDir);
57
+ localHash = compiled.manifest ? manifestHash(compiled.manifest) : null;
58
+ } catch {
59
+ // A config that doesn't compile can't be proven clean — refuse without
60
+ // --force rather than silently discarding whatever it holds.
61
+ fail(
62
+ "SCHEMA_DIRTY",
63
+ "monty.config.ts does not compile, so local schema edits cannot be verified against the registry. Fix it and deploy, or re-run with --force to REPLACE it (a .bak is kept).",
64
+ );
65
+ }
66
+ const cleanAgainst = state?.hash ?? body.hash;
67
+ if (localHash !== null && localHash !== cleanAgainst && localHash !== body.hash) {
68
+ fail(
69
+ "SCHEMA_DIRTY",
70
+ "monty.config.ts has schema changes that never reached the registry. Push them first (`monty dev` save or `monty deploy`), or discard them with --force (a .bak is kept).",
71
+ );
72
+ }
73
+ }
74
+
75
+ if (existsSync(configPath)) {
76
+ renameSync(configPath, `${configPath}.bak`);
77
+ console.log(`backup: monty.config.ts.bak`);
78
+ }
79
+ writeFileSync(configPath, manifestToConfig(body.manifest, { name: body.name, icon: body.icon ?? undefined }));
80
+ writeSchemaState(appDir, body.hash);
81
+ console.log(`schema: pulled "${slug}" (${Object.keys(body.manifest.tables).length} tables) -> monty.config.ts`);
82
+ console.log(`base: ${body.hash.slice(0, 12)} (.monty/schema.json — the CAS base for the next push)`);
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/TomasMonty/monty-v2.git",
@@ -21,8 +21,9 @@
21
21
  },
22
22
  "scripts": {
23
23
  "prepack": "node scripts/bundle-template.mjs",
24
- "typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs",
25
- "postinstall": "node bin/postinstall.mjs"
24
+ "typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs && node --check lib/schemaCodegen.mjs && node --check lib/schemaPull.mjs && node --check scripts/schema-roundtrip.mjs",
25
+ "postinstall": "node bin/postinstall.mjs",
26
+ "test:roundtrip": "node scripts/schema-roundtrip.mjs"
26
27
  },
27
28
  "dependencies": {
28
29
  "esbuild": "^0.28.1"
@@ -92,6 +92,73 @@ await update(row._id, { receipt }); // store descriptor, not
92
92
  const { url } = useFileUrl(app, row.receipt); // authenticated blob: URL for previews/downloads
93
93
  ```
94
94
 
95
+ ## Derived fields, metrics, pages (V2 — the platform renders these)
96
+
97
+ Your config can carry a whole database app the platform shell renders for
98
+ you — system table views, record drawers, dashboards — no React needed.
99
+ This SPA scaffold exists for the pages that ARE bespoke (`pages` entries
100
+ with `kind: "custom"` mount your routes inside that shell).
101
+
102
+ ```ts
103
+ import { defineApp, formula, lookup, montyDate, montyMoney, montyRef, rollup, self } from "@montytools/sdk";
104
+ import { z } from "zod";
105
+
106
+ export const app = defineApp({
107
+ slug: "pipeline",
108
+ tables: {
109
+ people: z.object({
110
+ name: z.string().min(1),
111
+ companyId: montyRef("companies"), // typed relation
112
+ commissionRate: z.number().default(0.1),
113
+ // Derived fields live IN the table, like spreadsheet columns:
114
+ companyName: lookup(z.string(), { ref: "companyId", field: "name" }),
115
+ monthlySales: rollup(montyMoney(), { // live aggregate (SUMIFS)
116
+ from: "sales",
117
+ where: { status: "won", salespersonId: self("_id") },
118
+ sum: "amount", over: "closedAt", range: "currentMonth",
119
+ }),
120
+ commission: formula(montyMoney(), "monthlySales * commissionRate"),
121
+ }),
122
+ companies: z.object({ name: z.string().min(1) }),
123
+ sales: z.object({
124
+ amount: montyMoney(),
125
+ status: z.enum(["open", "won", "lost"]).default("open"),
126
+ salespersonId: montyRef("people"),
127
+ closedAt: montyDate().optional(),
128
+ }),
129
+ },
130
+ metrics: { // app-level named numbers for dashboards + formulas (`metrics.wonThisMonth`)
131
+ wonThisMonth: rollup(montyMoney(), { from: "sales", where: { status: "won" }, sum: "amount", over: "closedAt", range: "currentMonth" }),
132
+ },
133
+ pages: {
134
+ people: { kind: "view", table: "people", summaries: { monthlySales: "sum" } },
135
+ overview: { kind: "dashboard", metrics: ["wonThisMonth"] },
136
+ reports: { kind: "custom", path: "/reports" }, // ← your src/routes/reports.tsx
137
+ },
138
+ });
139
+ ```
140
+
141
+ Rules that matter:
142
+
143
+ - **Formulas are strings**, not functions — the Monty expression grammar
144
+ (`+ - * / %`, comparisons, `&& || !`, `IF/ROUND/ABS/MIN/MAX`,
145
+ `metrics.<name>`). A formula sees only fields declared ABOVE it. Type
146
+ errors and unknown identifiers fail the compile with the fix inline.
147
+ - **Derived fields are read-only** — writing one through `useInsert`/
148
+ `useUpdate` is a `VALIDATION` error naming the field and its kind.
149
+ - Rollup `where` values are equality literals or `self("ownField")` ("this
150
+ row's value"). `sum:` names a numeric field; `count: true` counts.
151
+ - In custom pages, read metrics/rollups with `useMetric(app, "wonThisMonth")`
152
+ and `useAggregate(app, "people", "monthlySales", rowIds)`.
153
+ - **Datasets** (computed row sets — hidden data, joins): declare the row
154
+ shape in `datasets: { leaderboard: z.object({...}) }`, implement it as a
155
+ `defineDataset` export in `server/index.ts` (≤1,000 rows — aggregate or
156
+ filter server-side), consume with `useDataset(app, "leaderboard")` —
157
+ polled (focus + optional interval + your own writes), NOT live.
158
+ - `monty schema pull` regenerates this file from the platform if another
159
+ agent edited the schema remotely (the terminal will tell you when —
160
+ `MANIFEST_DRIFT`).
161
+
95
162
  ## Errors are instructions
96
163
 
97
164
  Every platform error is one line shaped like:
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.1.5",
13
+ "@montytools/sdk": "^0.2.0",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",