@montytools/cli 0.5.5 → 0.5.6

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
@@ -15,10 +15,9 @@ import { basename, dirname, join, relative, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { CATALOG, REGISTRIES } from "./catalog.mjs";
18
- import { compileAppConfig } from "../lib/compile.mjs";
19
- import { manifestHash } from "../lib/schemaCodegen.mjs";
20
- import { readSchemaState, schemaPull, writeSchemaState } from "../lib/schemaPull.mjs";
18
+ import { readSchemaState, schemaPull, writeGenModule, writeSchemaState } from "../lib/schemaPull.mjs";
21
19
  import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseKanbanFlag, parseViewSort, validateViewColumns } from "../lib/views.mjs";
20
+ import { formatViolations, lintStyles } from "../lib/styleLint.mjs";
22
21
 
23
22
  // MONTY_HOME overrides the state root (default ~/.monty): config.json,
24
23
  // apps/, and desktop.json all live under it. This is how a second, isolated
@@ -352,7 +351,7 @@ function findAppRoot(start) {
352
351
  }
353
352
  }
354
353
 
355
- // The identity stamp: { id, slug, name, icon, registryOwned? } — the app's
354
+ // The identity stamp: { id, slug, name, icon } — the app's
356
355
  // durable identity on this machine, independent of the config file.
357
356
  function readAppJson(dir) {
358
357
  try {
@@ -671,11 +670,7 @@ async function connect() {
671
670
  }
672
671
  mkdirSync(target, { recursive: true });
673
672
  writeConfigOnlyScaffold(target, { appId: app.id, slug, name: app.name, icon: app.icon });
674
- try {
675
- await schemaPull({ appDir: target, host, key, slug, force: true, compileAppConfig, fail });
676
- } catch {
677
- console.log("schema: could not sync the app's config yet — `monty schema pull` retries it");
678
- }
673
+ console.log("config: the workspace holds this app's config — read it with `monty schema`");
679
674
  }
680
675
 
681
676
  // The snapshot excludes .monty/ — the workspace row is the authority for
@@ -694,43 +689,63 @@ async function connect() {
694
689
  }
695
690
 
696
691
  // ── monty create ───────────────────────────────────────────────────────────
697
- // A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
698
- // the platform shell renders it. The presence of index.html is the marker
699
- // (every SPA template ships one; the config-only scaffold never does).
692
+ // A CONFIG-ONLY app: no SPA at all — the workspace config is the whole app
693
+ // and the platform shell renders it. The presence of index.html is the
694
+ // marker (every SPA template ships one; config-only folders never do).
700
695
  function isConfigOnlyApp(appDir) {
701
696
  return !existsSync(join(appDir, "index.html"));
702
697
  }
703
698
 
704
- // Literal read of the config's `publicFns: ["a", "b"]` list — the same
705
- // light-touch parse the SDK's vite plugin uses.
706
- function readPublicFnsLiteral(appDir) {
707
- try {
708
- const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
709
- const block = /publicFns:\s*\[([^\]]*)\]/m.exec(src)?.[1];
710
- if (!block) return undefined;
711
- const names = [...block.matchAll(/["']([a-zA-Z][a-zA-Z0-9_]*)["']/g)].map((m) => m[1]);
712
- return names.length > 0 ? names : undefined;
713
- } catch {
714
- return undefined;
699
+ // One-time migration off the config-as-code shape: monty.config.ts stops
700
+ // existing in app folders — the workspace owns the config and
701
+ // src/monty.gen.ts is its generated, never-hand-edited mirror. Runs at the
702
+ // front of `monty dev` / `monty save` / `monty schema pull`: generates the
703
+ // mirror, rewrites src imports onto it, and retires the config as a .bak.
704
+ // Demo folders are exempt (the demo rail compiles their config).
705
+ async function migrateConfigToGen(appDir, { host, key, slug }) {
706
+ const configPath = join(appDir, "monty.config.ts");
707
+ if (!existsSync(configPath) || existsSync(join(appDir, "demo.json"))) return false;
708
+ const srcDir = join(appDir, "src");
709
+ if (existsSync(srcDir)) {
710
+ if (!key) {
711
+ console.log("migrate: monty.config.ts is retired, but generating src/monty.gen.ts needs the workspace — run `monty login`, then any monty command migrates this folder.");
712
+ return false;
713
+ }
714
+ const res = await fetch(`${host}/api/schema?slug=${encodeURIComponent(slug)}`, {
715
+ headers: { authorization: `Bearer ${key}` },
716
+ }).catch(() => null);
717
+ const body = await res?.json().catch(() => null);
718
+ if (!res?.ok || !body?.ok) {
719
+ console.log("migrate: could not fetch the workspace config — monty.config.ts left in place this run (edits to it never land; `monty schema` is the config).");
720
+ return false;
721
+ }
722
+ writeGenModule(appDir, body.manifest ?? { slug, tables: {} }, { name: body.name, icon: body.icon });
723
+ if (body.hash) writeSchemaState(appDir, body.hash);
724
+ rewriteGenImports(srcDir);
715
725
  }
726
+ renameSync(configPath, `${configPath}.bak`);
727
+ console.log("migrated: monty.config.ts retired (kept as monty.config.ts.bak). The workspace owns the config — read it with `monty schema`, change it with `monty schema set`; src/monty.gen.ts mirrors it automatically.");
728
+ return true;
716
729
  }
717
730
 
718
- // Literal read of the config's `schedule` block ({ fn: "cron expr" })
719
- // the same light-touch parse the SDK's vite plugin uses for publicFns.
720
- // Registry-owned sessions read it this way so the cron ticker works
721
- // without a config compile.
722
- function readScheduleLiteral(appDir) {
723
- try {
724
- const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
725
- const block = /schedule:\s*{([^}]*)}/m.exec(src)?.[1];
726
- if (!block) return undefined;
727
- const out = {};
728
- for (const m of block.matchAll(/["']?([a-zA-Z][a-zA-Z0-9_]*)["']?\s*:\s*"([^"]+)"/g)) {
729
- out[m[1]] = m[2];
730
- }
731
- return Object.keys(out).length > 0 ? out : undefined;
732
- } catch {
733
- return undefined;
731
+ // Point every `monty.config` import in src/ at the generated module. The
732
+ // specifier is rewritten relative to each importing file (main.tsx imports
733
+ // "./monty.gen", a route imports "../monty.gen").
734
+ function rewriteGenImports(srcDir) {
735
+ const files = walk(srcDir).filter((f) => /\.(ts|tsx)$/.test(f));
736
+ for (const file of files) {
737
+ const src = readFileSync(file, "utf8");
738
+ if (!src.includes("monty.config")) continue;
739
+ let rel = relative(dirname(file), join(srcDir, "monty.gen")).replace(/\\/g, "/");
740
+ if (!rel.startsWith(".")) rel = `./${rel}`;
741
+ const out = src.replace(
742
+ /(["'])(?:\.\.?\/)+monty\.config(?:\.ts)?\1/g,
743
+ (_m, q) => `${q}${rel}${q}`,
744
+ );
745
+ if (out !== src) {
746
+ writeFileSync(file, out);
747
+ console.log(`migrate: ${relative(dirname(srcDir), file)} now imports the generated module`);
748
+ }
734
749
  }
735
750
  }
736
751
 
@@ -752,14 +767,15 @@ function discoverPages(appDir) {
752
767
 
753
768
  const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
754
769
 
755
- The app is rendered by the Monty platform from its config stored in the workspace —
756
- tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
757
- \`settings\`, and \`pages\`. There is no src/, no React, no build.
770
+ The app is rendered by the Monty platform from its config stored in the
771
+ workspace — tables (zod-shaped), derived fields (\`rollup\`/\`lookup\`/
772
+ \`formula\`), \`metrics\`, \`settings\`, and \`pages\`. There is no src/, no
773
+ React, no build — and no config file here: the workspace copy is the only
774
+ one.
758
775
 
759
- - The schema lives in the workspace, and its door is the schema API:
760
- read it with \`monty schema\`, change it with \`monty schema set '<json>'\` (or pipe: \`monty schema set -\`)
761
- (validated, CAS-guarded, live within seconds). monty.config.ts edits do
762
- NOT change a workspace-owned app's schema.
776
+ - The config's door is the schema API: read it with \`monty schema\`,
777
+ change it with \`monty schema set '<json>'\` (or pipe:
778
+ \`monty schema set -\`) — validated, CAS-guarded, live within seconds.
763
779
  - Formulas are strings in the Monty expression grammar, e.g.
764
780
  \`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
765
781
  ABOVE the formula and \`metrics.<name>\` are in scope.
@@ -770,41 +786,18 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
770
786
  with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
771
787
  with the record controls from \`@montytools/sdk/ui\`.
772
788
  - Need a bespoke page later? \`monty page add\` declares it and upgrades this
773
- app with a SPA scaffold; \`monty save\` ships the code.
789
+ app with a SPA scaffold (typed against the generated src/monty.gen.ts
790
+ mirror); \`monty save\` ships the code.
774
791
  `;
775
792
 
793
+ // A config-only folder is identity + instructions, nothing more: the
794
+ // workspace holds the app, so there is no config file, no package.json, no
795
+ // tsconfig — nothing that even looks editable.
776
796
  function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
777
797
  mkdirSync(target, { recursive: true });
778
- writeFileSync(join(target, "monty.config.ts"), `import { defineApp } from "@montytools/sdk";
779
-
780
- // This file IS the app: tables, derived fields, metrics, settings, pages.
781
- // The Monty platform renders it — no src/, no build. Declare tables as zod
782
- // objects; derive with rollup()/lookup()/formula(); see AGENTS.md.
783
- export const app = defineApp({
784
- id: "${appId}",
785
- slug: "${slug}",
786
- name: "${name}",
787
- icon: "${icon}",
788
- tables: {},
789
- });
790
-
791
- export type App = typeof app;
792
- `);
793
- writeFileSync(join(target, "package.json"), JSON.stringify({
794
- name: slug,
795
- private: true,
796
- type: "module",
797
- dependencies: { "@montytools/sdk": "latest", zod: "^4.4.3" },
798
- }, null, 2) + "\n");
799
- writeFileSync(join(target, "tsconfig.json"), JSON.stringify({
800
- compilerOptions: {
801
- target: "ES2022", module: "ESNext", moduleResolution: "bundler",
802
- strict: true, skipLibCheck: true, noEmit: true,
803
- },
804
- include: ["monty.config.ts"],
805
- }, null, 2) + "\n");
806
798
  writeFileSync(join(target, ".gitignore"), "node_modules/\n.monty/\n");
807
799
  writeFileSync(join(target, "AGENTS.md"), CONFIG_ONLY_AGENTS_MD);
800
+ writeAppJson(target, { id: appId, slug, name, icon });
808
801
  }
809
802
 
810
803
  async function create() {
@@ -861,11 +854,8 @@ async function create() {
861
854
  // bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
862
855
  // (\`--config-only\` stays accepted as the now-default no-op).
863
856
  if (!rest.includes("--spa")) {
864
- console.log(`create: ${slug} -> ${target} (config-only)`);
857
+ console.log(`create: ${slug} -> ${target} (config-only — the workspace holds the config)`);
865
858
  writeConfigOnlyScaffold(target, { appId, slug, name, icon });
866
- // The identity stamp is the app-root marker and identity source from
867
- // here on — the config file is just code.
868
- writeAppJson(target, { id: appId, slug, name, icon });
869
859
  // The user's brief lands at the top of AGENTS.md, same as SPA creates.
870
860
  const brief = flag("description");
871
861
  if (brief?.trim()) {
@@ -897,7 +887,7 @@ async function create() {
897
887
  }
898
888
  installSkills({ appDir: target });
899
889
  console.log(`created: ${target}`);
900
- console.log(`next: cd ${target} && monty install && monty dev`);
890
+ console.log(`next: cd ${target} && monty dev then \`monty schema set\` declares the tables`);
901
891
  return;
902
892
  }
903
893
 
@@ -907,7 +897,7 @@ async function create() {
907
897
  const templateDir = [
908
898
  join(pkgRoot, "template"),
909
899
  join(pkgRoot, "..", "template"),
910
- ].find((d) => existsSync(join(d, "monty.config.ts")));
900
+ ].find((d) => existsSync(join(d, "src", "main.tsx")));
911
901
  if (!templateDir) {
912
902
  fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
913
903
  }
@@ -934,16 +924,10 @@ async function create() {
934
924
  writeFileSync(gitignorePath, "node_modules/\ndist/\n.monty/\n.env.local\n.env\n");
935
925
  }
936
926
 
937
- // Stamp identity into the copied files. The id line is INSERTED (the
938
- // template ships without one only real creates have a server id).
939
- const configPath = join(target, "monty.config.ts");
940
- writeFileSync(
941
- configPath,
942
- readFileSync(configPath, "utf8")
943
- .replace(/^([ \t]*)slug: "[^"]*"/m, `$1id: "${appId}",\n$1slug: "${slug}"`)
944
- .replace(/name: "[^"]*"/, `name: "${name}"`)
945
- .replace(/icon: "[^"]*"/, `icon: "${icon}"`),
946
- );
927
+ // Identity into the copied files. The registry landed the empty config at
928
+ // registration; the generated mirror derives from it the template's
929
+ // placeholder src/monty.gen.ts is replaced wholesale.
930
+ writeGenModule(target, { slug, tables: {} }, { name, icon });
947
931
  const pkgPath = join(target, "package.json");
948
932
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
949
933
  pkg.name = slug;
@@ -1034,6 +1018,18 @@ function buildApp() {
1034
1018
  "The production build failed. Read the vite error above; it names the file to fix.");
1035
1019
  }
1036
1020
 
1021
+ // `monty style` — the token-vocabulary lint, standalone. The same check
1022
+ // runs advisory at `monty dev` and blocking inside `monty save`.
1023
+ async function styleCheck() {
1024
+ const appDir = requireAppDir("style");
1025
+ const violations = await lintStyles(appDir);
1026
+ if (violations.length === 0) {
1027
+ console.log("style: clean — every class is on the token vocabulary");
1028
+ return;
1029
+ }
1030
+ fail("STYLE_OFF_TOKENS", `${violations.length} off-token style${violations.length === 1 ? "" : "s"}:\n${await formatViolations(violations)}\nFix with the monty-design skill's vocabulary; a deliberate exception takes \`// monty-style-ignore\` on its line.`);
1031
+ }
1032
+
1037
1033
  function typecheckApp() {
1038
1034
  const appDir = requireAppDir("typecheck");
1039
1035
  // routeTree.gen.ts is generated by the build — without it tsc fails on a
@@ -1074,7 +1070,7 @@ async function freePort(start) {
1074
1070
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
1075
1071
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
1076
1072
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
1077
- const MIN_SDK = "0.2.5";
1073
+ const MIN_SDK = "0.2.6";
1078
1074
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
1079
1075
 
1080
1076
  function installedSdkVersion(appDir) {
@@ -1534,20 +1530,29 @@ async function dev() {
1534
1530
 
1535
1531
  installSkills({ appDir });
1536
1532
  ensureSdk(appDir);
1533
+ // Style advisory (non-blocking here; `monty save` enforces): put off-token
1534
+ // styling in the terminal the agent is watching, before the session opens.
1535
+ try {
1536
+ const styleViolations = await lintStyles(appDir);
1537
+ if (styleViolations.length > 0) {
1538
+ console.log(`style: ${styleViolations.length} off-token style${styleViolations.length === 1 ? "" : "s"} — \`monty style\` lists them; \`monty save\` refuses them`);
1539
+ }
1540
+ } catch {}
1537
1541
  // Identity comes from the .monty/app.json stamp (written by create and
1538
1542
  // connect) — the platform reads NOTHING from the config file, which is
1539
- // just code the bundle imports. `schedule` (a code-door declaration the
1540
- // session cron ticker needs) is read literally, the same way the vite
1541
- // plugin reads `publicFns`.
1543
+ // just code the bundle imports. Clock work is an `every` rule now the
1544
+ // platform's minute tick dispatches to this session's runtime over the
1545
+ // tunnel like any rule; the session runs no clock of its own.
1542
1546
  const stamp = readAppJson(appDir);
1543
1547
  if (typeof stamp?.slug !== "string" || !stamp.slug) {
1544
1548
  fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here to join an existing app, or `monty create <slug>` for a new one.");
1545
1549
  }
1546
- const meta = { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) };
1550
+ const meta = { slug: stamp.slug, name: stamp.name, icon: stamp.icon };
1547
1551
  const cfg = loadConfig();
1548
1552
  const host = cfg?.host ?? DEFAULT_HOST;
1549
- // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
1550
- // compile + push the platform shell renders the app.
1553
+ await migrateConfigToGen(appDir, { host, key: cfg?.key, slug: meta.slug });
1554
+ // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is heartbeats
1555
+ // only — the platform shell renders the app from the workspace config.
1551
1556
  const configOnly = isConfigOnlyApp(appDir);
1552
1557
  // Auto-pick a free port (agents run several apps side by side); an
1553
1558
  // explicit --port is honored strictly.
@@ -1566,13 +1571,12 @@ async function dev() {
1566
1571
  stdio: ["ignore", "pipe", "pipe"],
1567
1572
  });
1568
1573
  } else {
1569
- console.log(`dev: config-only app "${meta.slug}" — no vite; watching monty.config.ts`);
1574
+ console.log(`dev: config-only app "${meta.slug}" — no vite; the workspace owns the config (read it with \`monty schema\`)`);
1570
1575
  }
1571
1576
 
1572
1577
  let tunnelChild = null;
1573
1578
  let hbTimer = null;
1574
1579
  let touchTimer = null;
1575
- let cronTimer = null;
1576
1580
  let ended = false;
1577
1581
  let registeredOnce = false;
1578
1582
  // The schema-lives-in-the-workspace notice prints once per session.
@@ -1584,7 +1588,6 @@ async function dev() {
1584
1588
  // Once per registry change: the manifest hash we last tried to sync the
1585
1589
  // local config copy to (successful or refused — never loop on dirty).
1586
1590
  let syncAttemptedHash = null;
1587
- const configPath = join(appDir, "monty.config.ts");
1588
1591
 
1589
1592
  // Advertise this session. The touch timer (not the platform heartbeat,
1590
1593
  // which starts minutes late or never when logged out) keeps updatedAt
@@ -1617,40 +1620,6 @@ async function dev() {
1617
1620
  });
1618
1621
  touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
1619
1622
 
1620
- // The session cron runner: the Live counterpart is a real Cloudflare Cron
1621
- // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1622
- // `schedule` entries against the UTC clock once per minute and invokes the
1623
- // fn through the same /__monty/fn runtime (x-monty-schedule marks the
1624
- // lane, so ctx.viewer matches Live exactly). Fire-and-forget: a failing
1625
- // cron fn prints its instruction here and never blocks the loop.
1626
- let lastCronMinute = null;
1627
- function cronTick() {
1628
- const sched = meta?.schedule;
1629
- if (!sched || !loggedIn) return;
1630
- const now = new Date();
1631
- const minute = Math.floor(now.getTime() / 60_000);
1632
- if (minute === lastCronMinute) return;
1633
- lastCronMinute = minute;
1634
- for (const [fn, expr] of Object.entries(sched)) {
1635
- if (!cronMatches(expr, now)) continue;
1636
- console.log(`cron: "${expr}" → ${fn}() (UTC)`);
1637
- const t0 = Date.now();
1638
- fetch(`http://localhost:${port}/__monty/fn/${fn}`, {
1639
- method: "POST",
1640
- headers: { "content-type": "application/json", "x-monty-schedule": expr },
1641
- body: "{}",
1642
- }).then(async (r) => {
1643
- if (r.ok) {
1644
- console.log(`cron: ${fn} ok (${Date.now() - t0}ms)`);
1645
- } else {
1646
- const e = await r.json().catch(() => null);
1647
- console.log(`cron: ${fn} failed [${e?.code ?? r.status}] ${e?.fix ?? ""}`);
1648
- }
1649
- }).catch((e) => console.log(`cron: ${fn} unreachable — ${e?.message ?? e}`));
1650
- }
1651
- }
1652
- cronTimer = setInterval(cronTick, 20_000);
1653
-
1654
1623
  async function clearDevSession(timeoutMs = 2000) {
1655
1624
  if (cfg?.key) {
1656
1625
  try {
@@ -1669,7 +1638,6 @@ async function dev() {
1669
1638
  ended = true;
1670
1639
  if (hbTimer) clearInterval(hbTimer);
1671
1640
  if (touchTimer) clearInterval(touchTimer);
1672
- if (cronTimer) clearInterval(cronTimer);
1673
1641
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1674
1642
  // vite is a direct child (no npx wrapper), so this actually kills it —
1675
1643
  // a bare SIGTERM from the desktop must never orphan vite on the port.
@@ -1684,7 +1652,6 @@ async function dev() {
1684
1652
  ended = true;
1685
1653
  if (hbTimer) clearInterval(hbTimer);
1686
1654
  if (touchTimer) clearInterval(touchTimer);
1687
- if (cronTimer) clearInterval(cronTimer);
1688
1655
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1689
1656
  try { child?.kill(); } catch { /* already gone */ }
1690
1657
  console.log(`dev-session: superseded — ${fix}`);
@@ -1735,53 +1702,48 @@ async function dev() {
1735
1702
  }
1736
1703
  return false;
1737
1704
  }
1705
+ // publicFns rides every beat: dev.json mirrors the door-owned
1706
+ // allowlist so the vite runtime's /__monty/public gate follows a
1707
+ // `monty public set` / MCP change within one heartbeat.
1708
+ const beatExtras = Array.isArray(data?.publicFns) ? { publicFns: data.publicFns } : {};
1738
1709
  if (!registeredOnce) {
1739
1710
  registeredOnce = true;
1740
- sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
1711
+ sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now(), ...beatExtras });
1741
1712
  } else {
1742
- sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1713
+ sf.write({ loggedIn: true, lastHeartbeatAt: Date.now(), ...beatExtras });
1743
1714
  }
1744
1715
  if (!configIgnoredAnnounced) {
1745
1716
  configIgnoredAnnounced = true;
1746
- 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 '<json>'` (or pipe: `monty schema set -`).");
1717
+ console.log("schema: this app's config lives in the workspace. Read it: `monty schema`; change it: `monty schema set '<json>'` (or pipe: `monty schema set -`). src/monty.gen.ts mirrors it automatically — never edit that file.");
1747
1718
  }
1748
- // The local config copy follows the registry: when the stored manifest
1749
- // moved (another editor, the schema door) and the local file is clean,
1750
- // regenerate it in place the sync of the mechanical copy is
1751
- // automatic. Dirty or uncompilable files are left alone with the fix.
1719
+ // src/monty.gen.ts follows the registry: when the stored manifest
1720
+ // moved (another editor, the schema door), regenerate it in place
1721
+ // the file is generated, never hand-edited, so the overwrite is
1722
+ // unconditional. Demo folders are exempt (the demo rail compiles
1723
+ // their monty.config.ts).
1752
1724
  if (
1753
1725
  typeof data?.manifestHash === "string" &&
1754
- existsSync(configPath) &&
1755
1726
  data.manifestHash !== readSchemaState(appDir)?.hash &&
1756
- data.manifestHash !== syncAttemptedHash
1727
+ data.manifestHash !== syncAttemptedHash &&
1728
+ !existsSync(join(appDir, "demo.json"))
1757
1729
  ) {
1758
1730
  syncAttemptedHash = data.manifestHash;
1759
1731
  try {
1760
- // Already in sync (fresh checkout, no state file yet)? Stamp the
1761
- // base and leave the file alone — regenerate only on real drift.
1762
- let alreadySynced = false;
1763
- try {
1764
- const compiled = await compileAppConfig(appDir);
1765
- if (compiled.manifest && manifestHash(compiled.manifest) === data.manifestHash) {
1766
- writeSchemaState(appDir, data.manifestHash);
1767
- alreadySynced = true;
1768
- }
1769
- } catch { /* uncompilable — let schemaPull's dirty check narrate */ }
1770
- if (!alreadySynced) {
1771
- await schemaPull({
1772
- appDir,
1773
- host,
1774
- key: loadConfig()?.key ?? cfg.key,
1775
- slug: meta.slug,
1776
- force: false,
1777
- compileAppConfig,
1778
- fail: (code, fix) => {
1779
- throw new Error(`${code} — ${fix}`);
1780
- },
1781
- });
1732
+ const r = await schemaPull({
1733
+ appDir,
1734
+ host,
1735
+ key: loadConfig()?.key ?? cfg.key,
1736
+ slug: meta.slug,
1737
+ quiet: true,
1738
+ fail: (code, fix) => {
1739
+ throw new Error(`${code} — ${fix}`);
1740
+ },
1741
+ });
1742
+ if (r.wrote) {
1743
+ console.log(`schema: src/monty.gen.ts regenerated from the workspace (${data.manifestHash.slice(0, 12)})`);
1782
1744
  }
1783
1745
  } catch (e) {
1784
- console.log(`schema: the workspace manifest changed but the local copy was NOT regenerated (${String(e?.message ?? e).slice(0, 240)})`);
1746
+ console.log(`schema: the workspace config changed but src/monty.gen.ts was NOT regenerated (${String(e?.message ?? e).slice(0, 240)}) — \`monty schema pull\` retries it`);
1785
1747
  }
1786
1748
  }
1787
1749
  return true;
@@ -2148,26 +2110,28 @@ function resolveComponent(name) {
2148
2110
  // ── monty page add <name> ──────────────────────────────────────────────────
2149
2111
  // Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
2150
2112
  // use (config-only apps gain src/ + vite from the template — their
2151
- // monty.config.ts and AGENTS.md stay untouched), declares
2113
+ // AGENTS.md stays untouched), declares
2152
2114
  // `pages.<name> = { kind: "custom", path: "/<name>" }`, and writes the page
2153
2115
  // route. DECLARE-FIRST: on a workspace-owned app the entry lands through
2154
2116
  // the schema door BEFORE the code exists — a save carrying an undeclared
2155
2117
  // route refuses (DEPLOY_UNDECLARED_PAGE). The Shopify model: system pages
2156
2118
  // stay shell-rendered; only this page is the app's own code.
2157
2119
 
2158
- // Declare the page through the schema door. Returns "declared" | "already"
2159
- // (workspace-owned app) or "config" (manifest-less: the config file is
2160
- // still that app's editor, the caller registers the entry there).
2120
+ // Declare the page through the schema door (declare-first is mandatory: a
2121
+ // save carrying an undeclared route refuses). Returns "declared" | "already".
2161
2122
  async function declarePageThroughDoor(appDir, pageName) {
2162
2123
  const slug = readSlug(appDir);
2163
2124
  const { host, key } = loadConfig();
2164
- if (!slug || !key) return "config";
2125
+ if (!slug) fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` first.");
2126
+ if (!key) fail("NOT_LOGGED_IN", "Declaring a page lands in the workspace config. Run `monty login` first.");
2165
2127
  const read = await fetch(`${host}/api/schema?slug=${slug}`, {
2166
2128
  headers: { authorization: `Bearer ${key}` },
2167
2129
  }).catch(() => null);
2168
2130
  const readBody = await read?.json().catch(() => null);
2169
- if (!read?.ok || !readBody?.ok || readBody.manifest === null) return "config";
2170
- const manifest = readBody.manifest;
2131
+ if (!read?.ok || !readBody?.ok) {
2132
+ fail(readBody?.code ?? "HOST_UNREACHABLE", readBody?.fix ?? `Could not read the workspace config from ${host} — check the connection and retry.`);
2133
+ }
2134
+ const manifest = readBody.manifest ?? { slug, tables: {} };
2171
2135
  const keyOf = (n) => n.toLowerCase().replace(/[^a-z0-9]/g, "");
2172
2136
  const declared = Object.keys(manifest.pages ?? {}).find((n) => keyOf(n) === keyOf(pageName));
2173
2137
  if (declared) {
@@ -2193,7 +2157,12 @@ async function declarePageThroughDoor(appDir, pageName) {
2193
2157
  if (!res.ok || !body?.ok) {
2194
2158
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Declaring the page through the schema door failed — check the connection and retry.");
2195
2159
  }
2196
- try { writeSchemaState(appDir, body.hash); } catch { /* state is advisory */ }
2160
+ // Stamp + regenerate together (the stamp claims the mirror matches).
2161
+ try {
2162
+ const stamp = readAppJson(appDir);
2163
+ writeGenModule(appDir, { ...manifest, pages }, { name: stamp?.name, icon: stamp?.icon });
2164
+ writeSchemaState(appDir, body.hash);
2165
+ } catch { /* state is advisory */ }
2197
2166
  return "declared";
2198
2167
  }
2199
2168
 
@@ -2201,28 +2170,22 @@ async function addPage(appDir, pageName) {
2201
2170
  if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
2202
2171
  fail("INVALID_PAGE", 'Usage: monty page add <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2203
2172
  }
2204
- const configPath = join(appDir, "monty.config.ts");
2205
- if (!existsSync(configPath)) {
2206
- fail("NO_CONFIG", `No monty.config.ts in ${appDir} — run this inside a Monty app.`);
2173
+ const stamp = readAppJson(appDir);
2174
+ if (typeof stamp?.slug !== "string" || !stamp.slug) {
2175
+ fail("NOT_CONNECTED", `No app identity in ${appDir} (.monty/app.json) — run this inside a Monty app.`);
2207
2176
  }
2208
- const config = readFileSync(configPath, "utf8");
2209
- const appName = config.match(/name: "([^"]*)"/)?.[1] ?? pageName;
2177
+ const appName = stamp.name ?? pageName;
2210
2178
  const routeFile = join(appDir, "src", "routes", `${pageName}.tsx`);
2211
2179
  if (existsSync(routeFile)) {
2212
2180
  fail("PAGE_EXISTS", `src/routes/${pageName}.tsx already exists. Edit it, or pick a different page name.`);
2213
2181
  }
2214
- if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
2215
- console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
2216
- }
2217
2182
  // Declare BEFORE any code exists: if the door refuses, nothing to clean up.
2218
2183
  const declared = await declarePageThroughDoor(appDir, pageName);
2219
- if (declared !== "config") {
2220
- console.log(
2221
- declared === "declared"
2222
- ? `declared: pages.${pageName} through the schema door — live in the workspace now`
2223
- : `declared: pages.${pageName} already in the workspace manifest`,
2224
- );
2225
- }
2184
+ console.log(
2185
+ declared === "declared"
2186
+ ? `declared: pages.${pageName} through the schema door — live in the workspace now`
2187
+ : `declared: pages.${pageName} already in the workspace manifest`,
2188
+ );
2226
2189
 
2227
2190
  // First custom page on a config-only app: bring in the SPA scaffold.
2228
2191
  if (isConfigOnlyApp(appDir)) {
@@ -2230,7 +2193,7 @@ async function addPage(appDir, pageName) {
2230
2193
  const templateDir = [
2231
2194
  join(pkgRoot, "template"),
2232
2195
  join(pkgRoot, "..", "template"),
2233
- ].find((d) => existsSync(join(d, "monty.config.ts")));
2196
+ ].find((d) => existsSync(join(d, "src", "main.tsx")));
2234
2197
  if (!templateDir) {
2235
2198
  fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
2236
2199
  }
@@ -2242,7 +2205,7 @@ async function addPage(appDir, pageName) {
2242
2205
  const base = basename(src);
2243
2206
  if (["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base)) return false;
2244
2207
  // The app keeps its own identity files.
2245
- if (["monty.config.ts", "AGENTS.md", "CLAUDE.md"].includes(base)) return false;
2208
+ if (["AGENTS.md", "CLAUDE.md"].includes(base)) return false;
2246
2209
  return true;
2247
2210
  },
2248
2211
  });
@@ -2284,6 +2247,16 @@ async function addPage(appDir, pageName) {
2284
2247
  // the only route beside __root.
2285
2248
  const starter = join(appDir, "src", "routes", "index.tsx");
2286
2249
  if (existsSync(starter)) rmSync(starter);
2250
+ // The template's placeholder src/monty.gen.ts carries no real schema —
2251
+ // regenerate the mirror from the workspace so the new page types
2252
+ // against the actual tables.
2253
+ try {
2254
+ const { host, key } = loadConfig();
2255
+ await schemaPull({ appDir, host, key, slug: readSlug(appDir), quiet: true, fail: (c, f) => { throw new Error(`${c} — ${f}`); } });
2256
+ console.log("schema: src/monty.gen.ts generated from the workspace config");
2257
+ } catch (e) {
2258
+ console.log(`schema: could not generate src/monty.gen.ts (${String(e?.message ?? e).slice(0, 160)}) — \`monty schema pull\` retries it`);
2259
+ }
2287
2260
  appendFileSync(
2288
2261
  join(appDir, "AGENTS.md"),
2289
2262
  `\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 page add <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`,
@@ -2335,30 +2308,6 @@ function ${pageComponentName(pageName)}() {
2335
2308
  }
2336
2309
  `);
2337
2310
  console.log(`page: src/routes/${pageName}.tsx`);
2338
-
2339
- // Manifest-less apps only: the config file is still their editor, so the
2340
- // entry registers there (workspace-owned apps declared through the door
2341
- // above — a config edit would be inert).
2342
- if (declared === "config") {
2343
- const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
2344
- let next = null;
2345
- if (/^(\s*)pages:\s*{/m.test(config)) {
2346
- next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
2347
- } else {
2348
- // No pages block: add one right before the config's closing `});`.
2349
- const close = config.lastIndexOf("});");
2350
- if (close !== -1) {
2351
- next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
2352
- }
2353
- }
2354
- if (next) {
2355
- writeFileSync(configPath, next);
2356
- console.log(`config: pages.${pageName} registered in monty.config.ts`);
2357
- } else {
2358
- console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
2359
- }
2360
- }
2361
-
2362
2311
  console.log(`added: custom page "${pageName}"`);
2363
2312
  console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
2364
2313
  }
@@ -2536,9 +2485,9 @@ async function deploy() {
2536
2485
  fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
2537
2486
  }
2538
2487
 
2539
- // 1) Identity from the stamp; code-door declarations (publicFns,
2540
- // schedule) are read literally from the config file, the same way the
2541
- // vite plugin reads them.
2488
+ // 1) Identity from the stamp. Neither the /__monty/public allowlist nor
2489
+ // clock work rides a save: sharing is door-owned (`monty public set`)
2490
+ // and cron is an `every` rule (the rules door).
2542
2491
  const stamp = readAppJson(appDir);
2543
2492
  if (typeof stamp?.slug !== "string" || !stamp.slug) {
2544
2493
  fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here to join an existing app, or `monty create <slug>` for a new one.");
@@ -2547,12 +2496,11 @@ async function deploy() {
2547
2496
  slug: stamp.slug,
2548
2497
  name: stamp.name ?? stamp.slug,
2549
2498
  icon: stamp.icon,
2550
- publicFns: readPublicFnsLiteral(appDir),
2551
- schedule: readScheduleLiteral(appDir),
2552
2499
  };
2553
2500
  // `monty save "what changed"` — the message rides the meta for the
2554
2501
  // platform to render as this save's Activity row.
2555
2502
  if (message) meta.message = message;
2503
+ await migrateConfigToGen(appDir, { host: config.host, key: config.key, slug: meta.slug });
2556
2504
 
2557
2505
  // CONFIG-ONLY apps have no bundle — the platform shell renders them; the
2558
2506
  // save carries the source snapshot only.
@@ -2560,8 +2508,23 @@ async function deploy() {
2560
2508
  if (configOnly) {
2561
2509
  meta.configOnly = true;
2562
2510
  } else {
2563
- // 2) Fail fast locally before any upload. Build FIRST — it also generates
2564
- // src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
2511
+ // Refresh the generated mirror before the build/typecheck: code written
2512
+ // against a schema another surface just changed must see it.
2513
+ if (!existsSync(join(appDir, "demo.json"))) {
2514
+ try {
2515
+ await schemaPull({ appDir, host: config.host, key: config.key, slug: meta.slug, quiet: true, fail: (c, f) => { throw new Error(`${c} — ${f}`); } });
2516
+ } catch (e) {
2517
+ console.log(`schema: could not refresh src/monty.gen.ts (${String(e?.message ?? e).slice(0, 160)}) — building with the local copy`);
2518
+ }
2519
+ }
2520
+ // 2) Fail fast locally before any upload. Style lint first (cheapest,
2521
+ // same contract as the typecheck: off-token styling never ships) —
2522
+ // then build (it also generates src/routeTree.gen.ts, without which
2523
+ // tsc fails on a fresh checkout), then typecheck.
2524
+ const styleViolations = await lintStyles(appDir);
2525
+ if (styleViolations.length > 0) {
2526
+ fail("STYLE_OFF_TOKENS", `Off-token styling below — the platform look is tokens-only, and \`monty save\` never uploads styling off the vocabulary.\n${await formatViolations(styleViolations)}\nThe vocabulary is the monty-design skill; \`monty style\` re-checks. A deliberate exception takes \`// monty-style-ignore\` on its line.`);
2527
+ }
2565
2528
  run(appDir, "build", ["npx", "vite", "build"],
2566
2529
  "The production build failed. Read the vite error above; it names the file to fix.");
2567
2530
  run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
@@ -2579,26 +2542,8 @@ async function deploy() {
2579
2542
  // 3a) Server functions (optional): bundle server/index.ts into one worker
2580
2543
  // script and ride the SAME deploy. The manifest (fns) goes in meta so
2581
2544
  // the router gates /__monty/fn/* without a lookup.
2582
- const serverBundle = await bundleServerFns(appDir, meta.schedule);
2583
- const publicFns = Array.isArray(meta.publicFns) ? meta.publicFns : [];
2584
- const scheduleEntries = Object.entries(meta.schedule ?? {});
2585
- if (!serverBundle && (publicFns.length > 0 || scheduleEntries.length > 0)) {
2586
- fail("SERVER_DIR_MISSING",
2587
- "monty.config.ts declares publicFns/schedule, but this app has no server/index.ts. Create it with the named exports, or remove the declarations.");
2588
- }
2545
+ const serverBundle = await bundleServerFns(appDir);
2589
2546
  if (serverBundle) {
2590
- for (const name of publicFns) {
2591
- if (!serverBundle.fns.includes(name)) {
2592
- fail("PUBLIC_FN_UNKNOWN",
2593
- `publicFns names "${name}" but server/index.ts exports no such function. Export it (e.g. \`export async function ${name}(req, ctx) {…}\`) or remove it from monty.config.ts.`);
2594
- }
2595
- }
2596
- for (const [name] of scheduleEntries) {
2597
- if (!serverBundle.fns.includes(name)) {
2598
- fail("SCHEDULE_UNKNOWN_FN",
2599
- `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.`);
2600
- }
2601
- }
2602
2547
  if (serverBundle.fns.length > 0) meta.fns = serverBundle.fns;
2603
2548
  if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
2604
2549
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
@@ -2606,12 +2551,6 @@ async function deploy() {
2606
2551
  if (serverBundle.fns.length > 0) bundled.push(`${serverBundle.fns.length} function(s) (${serverBundle.fns.join(", ")})`);
2607
2552
  if (serverBundle.datasets.length > 0) bundled.push(`${serverBundle.datasets.length} dataset(s) (${serverBundle.datasets.join(", ")})`);
2608
2553
  console.log(`fns: bundled ${bundled.join(" + ")}`);
2609
- if (publicFns.length > 0) {
2610
- console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
2611
- }
2612
- if (scheduleEntries.length > 0) {
2613
- console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
2614
- }
2615
2554
  }
2616
2555
  // 3a½) Custom pages, by route-file convention (top-level src/routes/
2617
2556
  // files) — registered as fnsJson.pages so the shell's nav knows what this
@@ -2684,7 +2623,7 @@ async function deploy() {
2684
2623
  // fn-worker's makeFnWorker; esbuild bundles it for workerd. node: imports are
2685
2624
  // rejected at compile time — Live runs on Cloudflare Workers, not Node.
2686
2625
  // Returns { code, fns, datasets } (per-folder export names) or null.
2687
- async function bundleServerFns(appDir, schedule) {
2626
+ async function bundleServerFns(appDir) {
2688
2627
  const serverEntry = join(appDir, "server", "index.ts");
2689
2628
  const datasetsEntry = join(appDir, "datasets", "index.ts");
2690
2629
  const hasServer = existsSync(serverEntry);
@@ -2695,14 +2634,11 @@ async function bundleServerFns(appDir, schedule) {
2695
2634
  mkdirSync(tmpDir, { recursive: true });
2696
2635
  const entry = join(tmpDir, "fn-worker-entry.mjs");
2697
2636
  const out = join(tmpDir, "fn-worker-out.mjs");
2698
- // The schedule map is baked into the bundle: Cloudflare's scheduled()
2699
- // hands back only the matching cron expression, so the worker needs the
2700
- // expression→fn mapping at runtime.
2701
2637
  writeFileSync(entry, [
2702
2638
  hasServer ? `import * as appFns from "../server/index";` : `const appFns = {};`,
2703
2639
  hasDatasets ? `import * as appDatasets from "../datasets/index";` : `const appDatasets = {};`,
2704
2640
  `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
2705
- `export default makeFnWorker({ ...appFns, ...appDatasets }, { schedule: ${JSON.stringify(schedule ?? {})} });`,
2641
+ `export default makeFnWorker({ ...appFns, ...appDatasets });`,
2706
2642
  ].join("\n"));
2707
2643
  // Fail the deploy if server code reaches for Node built-ins — a Worker
2708
2644
  // can't run them, and a silent runtime crash on Live is the worst outcome.
@@ -2792,58 +2728,6 @@ function walk(dir) {
2792
2728
  // UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
2793
2729
  // names (JAN, MON). Deliberately forgiving: an unparsable field simply never
2794
2730
  // matches locally — Cloudflare is the syntax authority at deploy, so a bad
2795
- // expression fails there with its own message.
2796
- const CRON_MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
2797
- const CRON_DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
2798
-
2799
- function cronMatches(expr, date) {
2800
- const fields = String(expr).trim().split(/\s+/);
2801
- if (fields.length !== 5) return false;
2802
- const values = [
2803
- date.getUTCMinutes(),
2804
- date.getUTCHours(),
2805
- date.getUTCDate(),
2806
- date.getUTCMonth() + 1,
2807
- date.getUTCDay(),
2808
- ];
2809
- const bounds = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
2810
- return fields.every((field, i) => cronFieldMatches(field, values[i], bounds[i], i));
2811
- }
2812
-
2813
- function cronFieldMatches(field, value, [lo, hi], idx) {
2814
- const names = idx === 3 ? CRON_MONTHS : idx === 4 ? CRON_DAYS : null;
2815
- const num = (t) => {
2816
- const named = names?.[t.toLowerCase()];
2817
- if (named !== undefined) return named;
2818
- const n = Number(t);
2819
- return Number.isInteger(n) ? n : null;
2820
- };
2821
- for (const part of field.split(",")) {
2822
- const [rangeRaw, stepRaw] = part.split("/");
2823
- const step = stepRaw === undefined ? 1 : Number(stepRaw);
2824
- if (!Number.isInteger(step) || step < 1) continue;
2825
- let from;
2826
- let to;
2827
- if (rangeRaw === "*" || rangeRaw === "") {
2828
- from = lo;
2829
- to = hi;
2830
- } else if (rangeRaw.includes("-")) {
2831
- const [a, b] = rangeRaw.split("-");
2832
- from = num(a);
2833
- to = num(b);
2834
- } else {
2835
- from = num(rangeRaw);
2836
- to = stepRaw === undefined ? from : hi; // "5/10": from 5 to max, step 10
2837
- }
2838
- if (from === null || to === null || from > to) continue;
2839
- for (let v = from; v <= to; v += step) {
2840
- // day-of-week: cron accepts 7 for Sunday alongside 0
2841
- if (v === value || (idx === 4 && v === 7 && value === 0)) return true;
2842
- }
2843
- }
2844
- return false;
2845
- }
2846
-
2847
2731
  // ── monty data ─────────────────────────────────────────────────────────────
2848
2732
  // The agent verbs for OPERATING an app: read and write its records from any
2849
2733
  // terminal — no browser, no dev session. Auth is the mk_ key exchanged at
@@ -2962,7 +2846,7 @@ function flattenRow(doc) {
2962
2846
 
2963
2847
  function dataUsage() {
2964
2848
  console.log("usage: monty data <verb> [table] [flags] read/write an app's records");
2965
- console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
2849
+ console.log(" schema [table] the app's table shapes (read from the workspace)");
2966
2850
  console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
2967
2851
  console.log(" get <table> <id>");
2968
2852
  console.log(" insert <table> --data '<json|[json,…]>'");
@@ -3592,7 +3476,7 @@ if (command !== "dev" && command !== "logs" && command !== "support") {
3592
3476
  // manifest as JSON (and stamps the CAS base); edit that JSON and
3593
3477
  // `monty schema set '<json>'` (or `set -` piped) writes it back|->` writes it back through the one landing —
3594
3478
  // validated server-side, additive-only by default, CAS against what you
3595
- // read. `monty schema pull` (legacy) regenerates monty.config.ts.
3479
+ // read. `monty schema pull` regenerates the src/monty.gen.ts mirror.
3596
3480
  async function schemaCmd() {
3597
3481
  const verb = rest[0];
3598
3482
  const { host, key } = loadConfig() ?? {};
@@ -3601,20 +3485,12 @@ async function schemaCmd() {
3601
3485
 
3602
3486
  if (verb === "pull") {
3603
3487
  const dir = appDir ?? process.cwd();
3604
- let slug = rest.slice(1).find((a) => !a.startsWith("--"));
3488
+ const slug = rest.slice(1).find((a) => !a.startsWith("--")) ?? readSlug(dir);
3605
3489
  if (!slug) {
3606
- try {
3607
- slug = (await compileAppConfig(dir)).slug;
3608
- } catch {
3609
- fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
3610
- }
3490
+ fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — or run it inside an app folder (.monty/app.json carries the identity).");
3611
3491
  }
3612
- await schemaPull({
3613
- appDir: dir, host, key, slug,
3614
- force: rest.includes("--force"),
3615
- compileAppConfig,
3616
- fail,
3617
- });
3492
+ const migrated = await migrateConfigToGen(dir, { host, key, slug });
3493
+ if (!migrated) await schemaPull({ appDir: dir, host, key, slug, fail });
3618
3494
  return;
3619
3495
  }
3620
3496
 
@@ -3661,7 +3537,14 @@ async function schemaCmd() {
3661
3537
  }
3662
3538
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the config failed — is the Monty host reachable?");
3663
3539
  }
3664
- if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3540
+ // Stamp + regenerate together (the stamp claims the mirror matches)
3541
+ // the just-pushed document IS the new config, so the mirror updates
3542
+ // instantly instead of waiting a heartbeat.
3543
+ if (appDir && readSlug(appDir) === slug) {
3544
+ const stamp = readAppJson(appDir);
3545
+ writeGenModule(appDir, manifest, { name: stamp?.name, icon: stamp?.icon });
3546
+ writeSchemaState(appDir, body.hash);
3547
+ }
3665
3548
  console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
3666
3549
  return;
3667
3550
  }
@@ -3684,11 +3567,86 @@ async function schemaCmd() {
3684
3567
  console.log(JSON.stringify({ slug, tables: {} }, null, 2));
3685
3568
  return;
3686
3569
  }
3687
- if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3570
+ // The state stamp says "src/monty.gen.ts matches this hash" (it is also
3571
+ // the CAS base) — so stamping ALWAYS travels with a regeneration, or the
3572
+ // heartbeat would read the fresh stamp and leave a stale mirror in place.
3573
+ if (appDir && readSlug(appDir) === slug) {
3574
+ writeGenModule(appDir, body.manifest, { name: body.name, icon: body.icon });
3575
+ writeSchemaState(appDir, body.hash);
3576
+ }
3688
3577
  console.error(`# ${slug} — config hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3689
3578
  console.log(JSON.stringify(body.manifest, null, 2));
3690
3579
  }
3691
3580
 
3581
+ // ── monty public — the shared-functions door ──────────────────────────────
3582
+ // Which server functions answer publicly at /__monty/public/<name> on the
3583
+ // app origin. Door-owned workspace state (never a save ride): `set` replaces
3584
+ // the whole list and takes effect on Live immediately (the host patches the
3585
+ // deploy pointer) and in a running session within one heartbeat.
3586
+ async function publicCmd() {
3587
+ const { host, key } = loadConfig() ?? {};
3588
+ if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
3589
+ const appDir = findAppRoot(process.cwd());
3590
+ const verb = rest[0];
3591
+ const flagValue = (name) => {
3592
+ const i = rest.indexOf(name);
3593
+ return i >= 0 ? rest[i + 1] : undefined;
3594
+ };
3595
+ const slug = flagValue("--app") ?? (appDir ? readSlug(appDir) : null);
3596
+ if (!slug) {
3597
+ fail("INVALID_SLUG", "Usage: monty public [set <names…>|--none] [--app <slug>] — or run it inside an app folder.");
3598
+ }
3599
+
3600
+ if (verb === "set") {
3601
+ const names = rest.includes("--none")
3602
+ ? []
3603
+ : rest
3604
+ .slice(1)
3605
+ .filter((a) => !a.startsWith("--") && a !== flagValue("--app"))
3606
+ .flatMap((a) => a.split(","))
3607
+ .map((a) => a.trim())
3608
+ .filter(Boolean);
3609
+ if (names.length === 0 && !rest.includes("--none")) {
3610
+ fail("PUBLIC_USAGE", "Usage: monty public set <name> [<name>…] — the COMPLETE new list (it replaces, never merges). `monty public set --none` closes everything.");
3611
+ }
3612
+ const res = await fetch(`${host}/api/public-fns`, {
3613
+ method: "POST",
3614
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
3615
+ body: JSON.stringify({ slug, publicFns: names }),
3616
+ });
3617
+ const body = await res.json().catch(() => null);
3618
+ if (!res.ok || !body?.ok) {
3619
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the shared functions failed — is the Monty host reachable?");
3620
+ }
3621
+ if (body.publicFns.length === 0) {
3622
+ console.log(`public: none — "${slug}" answers nothing at /__monty/public`);
3623
+ return;
3624
+ }
3625
+ for (const name of body.publicFns) {
3626
+ const live = body.served?.includes(name);
3627
+ console.log(`public: /__monty/public/${name} — ${live ? "open to the internet NOW; verify signatures in the function" : "declared; opens when a save ships the function"}`);
3628
+ }
3629
+ return;
3630
+ }
3631
+
3632
+ // Default: LIST.
3633
+ const res = await fetch(`${host}/api/public-fns?slug=${slug}`, {
3634
+ headers: { authorization: `Bearer ${key}` },
3635
+ });
3636
+ const body = await res.json().catch(() => null);
3637
+ if (!res.ok || !body?.ok) {
3638
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the shared functions — check the connection and `monty login`.");
3639
+ }
3640
+ if (!body.publicFns?.length) {
3641
+ console.log(`public: none — "${slug}" answers nothing at /__monty/public (share with \`monty public set <name>\`)`);
3642
+ return;
3643
+ }
3644
+ const registered = new Set(body.fns ?? []);
3645
+ for (const name of body.publicFns) {
3646
+ console.log(`public: /__monty/public/${name}${registered.has(name) ? "" : " — not in the deployed bundle (opens when a save ships it)"}`);
3647
+ }
3648
+ }
3649
+
3692
3650
  switch (command) {
3693
3651
  case "login":
3694
3652
  await login();
@@ -3733,6 +3691,9 @@ switch (command) {
3733
3691
  case "typecheck":
3734
3692
  typecheckApp();
3735
3693
  break;
3694
+ case "style":
3695
+ await styleCheck();
3696
+ break;
3736
3697
  case "save":
3737
3698
  await deploy();
3738
3699
  break;
@@ -3742,6 +3703,9 @@ switch (command) {
3742
3703
  case "schema":
3743
3704
  await schemaCmd();
3744
3705
  break;
3706
+ case "public":
3707
+ await publicCmd();
3708
+ break;
3745
3709
  case "views":
3746
3710
  await views();
3747
3711
  break;
@@ -3777,11 +3741,12 @@ function printHelp() {
3777
3741
  console.log(" current which app folder am I in?");
3778
3742
  console.log(" history [slug] saved-version history, one row per save (like `git log`)");
3779
3743
  console.log(" pull <slug> [--version H] [--force] restore a source snapshot into the managed home");
3780
- console.log(" install / build / typecheck app lifecycle (pnpm/npm picked for you)");
3744
+ console.log(" install / build / typecheck / style app lifecycle + the token-vocabulary lint");
3781
3745
  console.log(" data <verb> [table] [flags] read/write an app's records from the terminal");
3782
3746
  console.log(" schema [slug] | set <json|-> read/write the app's config, stored in the workspace (validated, CAS)");
3783
3747
  console.log(" views <list|set|update|remove> <table> manage shared saved views on a record page");
3784
3748
  console.log(" secret <set|rm> <KEY> per-app server-function secrets (write-only)");
3749
+ console.log(" public [set <names…>|--none] which server functions answer publicly at /__monty/public");
3785
3750
  console.log(" support <status|enable|disable|submit> opt in to agent-authored platform reports");
3786
3751
  console.log(" skills install/refresh the agent build skill");
3787
3752
  console.log("");