@montytools/cli 0.5.4 → 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,16 +15,14 @@ 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 { CompileError, compileAppConfig } from "../lib/compile.mjs";
19
- import { manifestHash } from "../lib/schemaCodegen.mjs";
20
- import { readSchemaState, schemaPull, writeSchemaState } from "../lib/schemaPull.mjs";
21
- import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseViewSort, validateViewColumns } from "../lib/views.mjs";
18
+ import { readSchemaState, schemaPull, writeGenModule, writeSchemaState } from "../lib/schemaPull.mjs";
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
25
24
  // Monty state coexists on one machine — the desktop's dev channel points it
26
- // at ~/.monty-dev so platform development never touches the real state. A
27
- // custom root is a sandbox: the legacy visible home (~/Monty) is not scanned.
25
+ // at ~/.monty-dev so platform development never touches the real state.
28
26
  const CONFIG_DIR = process.env.MONTY_HOME
29
27
  ? resolve(process.env.MONTY_HOME)
30
28
  : join(homedir(), ".monty");
@@ -50,11 +48,9 @@ const ANSI_RE = new RegExp(
50
48
  // Every app's source lives in one predictable, hidden place: `monty create`
51
49
  // registers the app first and the server-minted id names the folder
52
50
  // (~/.monty/apps/<id>) — id-keyed because slugs may be renamed later; the
53
- // `id` stamped into monty.config.ts is the durable identity. `monty login`
54
- // provisions the home; --dir overrides per create. Pre-id apps in the legacy
55
- // visible home (~/Monty) keep working — every scan reads both.
51
+ // `id` stamped into .monty/app.json is the durable identity. `monty login`
52
+ // provisions the home; --dir overrides per create.
56
53
  const MONTY_HOME = join(CONFIG_DIR, "apps");
57
- const LEGACY_MONTY_HOME = join(homedir(), "Monty");
58
54
 
59
55
  const [, , command, ...rest] = process.argv;
60
56
 
@@ -116,22 +112,16 @@ function findMontyrcHost(startDir) {
116
112
  return null;
117
113
  }
118
114
 
119
- // Reads config.json in either shape: legacy { host, key } (migrated on the
120
- // next login) or { defaultHost, profiles: { [host]: { key } } }.
115
+ // Reads config.json: { defaultHost, profiles: { [host]: { key } } }.
121
116
  function normalizedConfig() {
122
117
  let raw = null;
123
118
  try {
124
119
  raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
125
120
  } catch { /* not logged in anywhere yet */ }
126
- if (!raw) return { defaultHost: null, profiles: {} };
127
- if (raw.profiles && typeof raw.profiles === "object") {
128
- return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
121
+ if (!raw?.profiles || typeof raw.profiles !== "object") {
122
+ return { defaultHost: null, profiles: {} };
129
123
  }
130
- const legacyHost = (raw.host ?? DEFAULT_HOST).replace(/\/+$/, "");
131
- return {
132
- defaultHost: legacyHost,
133
- profiles: raw.key ? { [legacyHost]: { key: raw.key } } : {},
134
- };
124
+ return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
135
125
  }
136
126
 
137
127
  function resolveHost() {
@@ -344,11 +334,9 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
344
334
  }
345
335
  }
346
336
 
347
- // ── monty current / select / apps ───────────────────────────────────────────
348
- // Folder management users never think about: every app lives in ~/Monty,
349
- // `current` says where you are, `select` prints the folder for cd $(...).
337
+ // ── monty current ──────────────────────────────────────────────────────────
350
338
  // The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
351
- // create and pull) or, for older folders, monty.config.ts.
339
+ // create and connect) or, transitionally, monty.config.ts.
352
340
  function isAppRoot(dir) {
353
341
  return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
354
342
  }
@@ -363,7 +351,7 @@ function findAppRoot(start) {
363
351
  }
364
352
  }
365
353
 
366
- // The identity stamp: { id, slug, name, icon, registryOwned? } — the app's
354
+ // The identity stamp: { id, slug, name, icon } — the app's
367
355
  // durable identity on this machine, independent of the config file.
368
356
  function readAppJson(dir) {
369
357
  try {
@@ -400,58 +388,15 @@ function readIdFromConfig(dir) {
400
388
  }
401
389
  }
402
390
 
403
- function scanAppsHome(root) {
404
- if (!existsSync(root)) return [];
405
- return readdirSync(root)
406
- .map((name) => join(root, name))
407
- .filter((p) => isAppRoot(p))
408
- .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
409
- }
410
-
411
- function listLocalApps() {
412
- // A MONTY_HOME sandbox lists only its own apps — leaking the legacy home
413
- // into an isolated root would defeat the isolation.
414
- return process.env.MONTY_HOME
415
- ? scanAppsHome(MONTY_HOME)
416
- : [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
417
- }
418
-
419
391
  function current() {
420
392
  const root = findAppRoot(process.cwd());
421
393
  if (!root) {
422
- fail("NOT_IN_APP", `You are not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one.`);
394
+ fail("NOT_IN_APP", "You are not inside a Monty app. cd into the app's folder (`monty connect <slug>` pulls a copy into any folder).");
423
395
  }
424
396
  console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
425
397
  const id = readIdFromConfig(root);
426
398
  if (id) console.log(`id: ${id}`);
427
399
  console.log(`path: ${root}`);
428
- if (!root.startsWith(MONTY_HOME) && !root.startsWith(LEGACY_MONTY_HOME)) {
429
- console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
430
- }
431
- }
432
-
433
- function select() {
434
- const slug = rest.find((a) => !a.startsWith("--"));
435
- if (!slug) {
436
- fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
437
- }
438
- const apps = listLocalApps();
439
- const hit = apps.find((a) => a.slug === slug || a.id === slug || basename(a.path) === slug);
440
- if (!hit) {
441
- const known = apps.map((a) => a.slug).join(", ") || "(none)";
442
- fail("APP_NOT_LOCAL", `No local source for "${slug}" on this machine. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
443
- }
444
- // Bare path on stdout so command substitution works: cd "$(monty select x)"
445
- console.log(hit.path);
446
- }
447
-
448
- function apps() {
449
- const local = listLocalApps();
450
- if (!local.length) {
451
- console.log(`no local apps in ${MONTY_HOME} — create one with \`monty create <slug>\``);
452
- return;
453
- }
454
- for (const a of local) console.log(`${a.slug}\t${a.path}`);
455
400
  }
456
401
 
457
402
  // Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
@@ -488,7 +433,7 @@ function readSlug(appDir) {
488
433
  // (Not `monty logs` — that tails the dev shell.)
489
434
  async function versionsLog() {
490
435
  const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
491
- if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
436
+ if (!slug) fail("NO_SLUG", "Usage: monty history [slug] — or run it inside an app folder.");
492
437
  const { host, key } = loadConfig();
493
438
  if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
494
439
  const res = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
@@ -531,7 +476,7 @@ async function pull() {
531
476
  }
532
477
  const app = appsBody.apps.find((a) => a.slug === slug);
533
478
  if (!app) {
534
- fail("APP_NOT_FOUND", `No app "${slug}" in this workspace. \`monty apps\` lists what exists.`);
479
+ fail("APP_NOT_FOUND", `No app "${slug}" in this workspace check the app list at ${host}.`);
535
480
  }
536
481
  // --version <hash-prefix>: restore a specific snapshot from `monty log`
537
482
  // instead of the newest one. Prefixes resolve against the history list.
@@ -548,7 +493,7 @@ async function pull() {
548
493
  }
549
494
  const matches = vbody.versions.filter((v) => v.hash.startsWith(versionFlag));
550
495
  if (matches.length === 0) {
551
- fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty log ${slug}\` lists what exists.`);
496
+ fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty history ${slug}\` lists what exists.`);
552
497
  }
553
498
  if (matches.length > 1) {
554
499
  fail("VERSION_AMBIGUOUS", `"${versionFlag}" matches ${matches.length} versions — use more characters of the hash.`);
@@ -558,7 +503,10 @@ async function pull() {
558
503
  } else if (!app.sourceHash) {
559
504
  fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each \`monty save\`. Run it once from the machine that has the source, then pull works everywhere.`);
560
505
  }
561
- const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
506
+ if (!app.id) {
507
+ fail("APP_ID_MISSING", `"${slug}" has no app id in the workspace registry — save it once from a machine that has its source, then pull works.`);
508
+ }
509
+ const target = join(MONTY_HOME, app.id);
562
510
  if (existsSync(target) && !rest.includes("--force")) {
563
511
  fail("DIR_EXISTS", `${target} already exists and may hold unsaved work. Compare it with the saved version first; re-run with --force to REPLACE it with the snapshot.`);
564
512
  }
@@ -620,30 +568,184 @@ async function pull() {
620
568
  console.log("next: `monty install`, then `monty dev`.");
621
569
  }
622
570
 
571
+ // ── monty connect ──────────────────────────────────────────────────────────
572
+ // The one command that turns ANY folder into a working copy of a cloud app:
573
+ // `monty connect <slug> [dir]` (dir defaults to the current folder) pulls
574
+ // the app's files, installs dependencies, stamps identity, and registers
575
+ // the checkout so the desktop's picker sees it. Apps without a source
576
+ // snapshot but with a manifest get the config-only scaffold synced from the
577
+ // registry. Multiple copies of one app on a machine are fine.
578
+ async function connect() {
579
+ const [slugArg, dirArg] = rest.filter((a) => !a.startsWith("--"));
580
+ if (!slugArg || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slugArg)) {
581
+ fail("INVALID_SLUG", "Usage: monty connect <slug> [dir] — pulls the app into dir (default: the current folder, which must be empty).");
582
+ }
583
+ const slug = slugArg;
584
+ const { host, key } = loadConfig();
585
+ if (!key) {
586
+ fail("NOT_LOGGED_IN", `Connecting needs your workspace (${host}). Run \`monty login\` first.`);
587
+ }
588
+ const appsRes = await fetch(`${host}/api/apps`, { headers: { authorization: `Bearer ${key}` } });
589
+ const appsBody = await appsRes.json().catch(() => null);
590
+ if (!appsRes.ok || !appsBody?.ok) {
591
+ fail(appsBody?.code ?? `HTTP_${appsRes.status}`, appsBody?.fix ?? "Could not list workspace apps — check the connection and `monty login`.");
592
+ }
593
+ const app = appsBody.apps.find((a) => a.slug === slug);
594
+ if (!app) {
595
+ fail("APP_NOT_FOUND", `No app "${slug}" in this workspace — check the app list at ${host}.`);
596
+ }
597
+
598
+ const target = resolve(process.cwd(), dirArg ?? ".");
599
+ if (existsSync(target)) {
600
+ if (!statSync(target).isDirectory()) {
601
+ fail("NOT_A_DIRECTORY", `${target} is a file. Point \`monty connect\` at a folder.`);
602
+ }
603
+ if (readdirSync(target).filter((n) => n !== ".DS_Store").length > 0) {
604
+ // A folder that already holds THIS app needs nothing — the working
605
+ // copy is untouched.
606
+ const stamp = readAppJson(target);
607
+ if (stamp && ((app.id && stamp.id === app.id) || stamp.slug === slug)) {
608
+ console.log(`connected: ${slug} -> ${target} (already a copy of this app)`);
609
+ console.log("next: `monty dev`");
610
+ return;
611
+ }
612
+ fail("DIR_NOT_EMPTY", `${target} is not empty. Connect pulls the app's files INTO the folder — run it in an empty one, or name a new folder: \`monty connect ${slug} ${slug}\`.`);
613
+ }
614
+ }
615
+
616
+ if (app.sourceHash) {
617
+ console.log(`connect: ${slug} <- ${host}`);
618
+ const res = await fetch(`${host}/api/source?slug=${slug}`, {
619
+ headers: { authorization: `Bearer ${key}` },
620
+ });
621
+ if (!res.ok) {
622
+ const b = await res.json().catch(() => null);
623
+ fail(b?.code ?? `HTTP_${res.status}`, b?.fix ?? "Downloading the snapshot failed — retry.");
624
+ }
625
+ const buf = Buffer.from(await res.arrayBuffer());
626
+ const hash = createHash("sha256").update(buf).digest("hex");
627
+ if (hash !== app.sourceHash) {
628
+ fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
629
+ }
630
+ // Extract into a sibling staging folder first — a failed extract never
631
+ // leaves a half-written copy. An existing (empty) target keeps its
632
+ // inode: staging contents MOVE in, so a shell sitting in it stays sane.
633
+ const staging = `${target}.connect-tmp`;
634
+ rmSync(staging, { recursive: true, force: true });
635
+ mkdirSync(staging, { recursive: true });
636
+ const tarFile = join(staging, ".source.tar.gz");
637
+ writeFileSync(tarFile, buf);
638
+ const untar = spawnSync("tar", ["-xzf", tarFile, "-C", staging], { stdio: "pipe" });
639
+ rmSync(tarFile, { force: true });
640
+ if (untar.status !== 0) {
641
+ rmSync(staging, { recursive: true, force: true });
642
+ fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
643
+ }
644
+ if (existsSync(target)) {
645
+ for (const name of readdirSync(staging)) renameSync(join(staging, name), join(target, name));
646
+ rmSync(staging, { recursive: true, force: true });
647
+ } else {
648
+ renameSync(staging, target);
649
+ }
650
+ try {
651
+ const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
652
+ writeFileSync(
653
+ join(target, ".env.local"),
654
+ `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
655
+ );
656
+ } catch {
657
+ console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
658
+ }
659
+ mkdirSync(join(target, ".monty"), { recursive: true });
660
+ writeFileSync(
661
+ join(target, ".monty", "source.json"),
662
+ JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
663
+ );
664
+ } else {
665
+ // No snapshot: the app's config in the workspace IS the app — a
666
+ // config-only scaffold synced from it is a complete working copy.
667
+ console.log(`connect: ${slug} <- ${host} (config-only — the workspace holds the app's config)`);
668
+ if (typeof app.id !== "string" || !/^[a-z0-9]{10,64}$/i.test(app.id)) {
669
+ fail("APP_ID_MISSING", `"${slug}" has no usable app id in the workspace registry — save it once from a machine that has it, then connect works.`);
670
+ }
671
+ mkdirSync(target, { recursive: true });
672
+ writeConfigOnlyScaffold(target, { appId: app.id, slug, name: app.name, icon: app.icon });
673
+ console.log("config: the workspace holds this app's config — read it with `monty schema`");
674
+ }
675
+
676
+ // The snapshot excludes .monty/ — the workspace row is the authority for
677
+ // identity, so stamp it fresh.
678
+ writeAppJson(target, {
679
+ ...(app.id ? { id: app.id } : {}),
680
+ slug,
681
+ ...(app.name ? { name: app.name } : {}),
682
+ ...(app.icon ? { icon: app.icon } : {}),
683
+ });
684
+ if (existsSync(join(target, "package.json"))) {
685
+ installDepsIn(target);
686
+ }
687
+ console.log(`connected: ${slug} -> ${target}`);
688
+ console.log(target === process.cwd() ? "next: `monty dev`" : `next: cd ${dirArg ?? target} && monty dev`);
689
+ }
690
+
623
691
  // ── monty create ───────────────────────────────────────────────────────────
624
- // A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
625
- // the platform shell renders it. The presence of index.html is the marker
626
- // (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).
627
695
  function isConfigOnlyApp(appDir) {
628
696
  return !existsSync(join(appDir, "index.html"));
629
697
  }
630
698
 
631
- // Literal read of the config's `schedule` block ({ fn: "cron expr" }) —
632
- // the same light-touch parse the SDK's vite plugin uses for publicFns.
633
- // Registry-owned sessions read it this way so the cron ticker works
634
- // without a config compile.
635
- function readScheduleLiteral(appDir) {
636
- try {
637
- const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
638
- const block = /schedule:\s*{([^}]*)}/m.exec(src)?.[1];
639
- if (!block) return undefined;
640
- const out = {};
641
- for (const m of block.matchAll(/["']?([a-zA-Z][a-zA-Z0-9_]*)["']?\s*:\s*"([^"]+)"/g)) {
642
- out[m[1]] = m[2];
643
- }
644
- return Object.keys(out).length > 0 ? out : undefined;
645
- } catch {
646
- 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);
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;
729
+ }
730
+
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
+ }
647
749
  }
648
750
  }
649
751
 
@@ -665,14 +767,15 @@ function discoverPages(appDir) {
665
767
 
666
768
  const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
667
769
 
668
- The app is rendered by the Monty platform from its WORKSPACE manifest
669
- tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
670
- \`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.
671
775
 
672
- - The schema lives in the workspace, and its door is the schema API:
673
- read it with \`monty schema\`, change it with \`monty schema set <file|->\`
674
- (validated, CAS-guarded, live within seconds). monty.config.ts edits do
675
- 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.
676
779
  - Formulas are strings in the Monty expression grammar, e.g.
677
780
  \`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
678
781
  ABOVE the formula and \`metrics.<name>\` are in scope.
@@ -682,42 +785,19 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
682
785
  system record page cannot express. If the page is still one table, start
683
786
  with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
684
787
  with the record controls from \`@montytools/sdk/ui\`.
685
- - Need a bespoke page later? \`monty add page\` declares it and upgrades this
686
- app with a SPA scaffold; \`monty save\` ships the code.
788
+ - Need a bespoke page later? \`monty page add\` declares it and upgrades this
789
+ app with a SPA scaffold (typed against the generated src/monty.gen.ts
790
+ mirror); \`monty save\` ships the code.
687
791
  `;
688
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.
689
796
  function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
690
797
  mkdirSync(target, { recursive: true });
691
- writeFileSync(join(target, "monty.config.ts"), `import { defineApp } from "@montytools/sdk";
692
-
693
- // This file IS the app: tables, derived fields, metrics, settings, pages.
694
- // The Monty platform renders it — no src/, no build. Declare tables as zod
695
- // objects; derive with rollup()/lookup()/formula(); see AGENTS.md.
696
- export const app = defineApp({
697
- id: "${appId}",
698
- slug: "${slug}",
699
- name: "${name}",
700
- icon: "${icon}",
701
- tables: {},
702
- });
703
-
704
- export type App = typeof app;
705
- `);
706
- writeFileSync(join(target, "package.json"), JSON.stringify({
707
- name: slug,
708
- private: true,
709
- type: "module",
710
- dependencies: { "@montytools/sdk": "latest", zod: "^4.4.3" },
711
- }, null, 2) + "\n");
712
- writeFileSync(join(target, "tsconfig.json"), JSON.stringify({
713
- compilerOptions: {
714
- target: "ES2022", module: "ESNext", moduleResolution: "bundler",
715
- strict: true, skipLibCheck: true, noEmit: true,
716
- },
717
- include: ["monty.config.ts"],
718
- }, null, 2) + "\n");
719
798
  writeFileSync(join(target, ".gitignore"), "node_modules/\n.monty/\n");
720
799
  writeFileSync(join(target, "AGENTS.md"), CONFIG_ONLY_AGENTS_MD);
800
+ writeAppJson(target, { id: appId, slug, name, icon });
721
801
  }
722
802
 
723
803
  async function create() {
@@ -725,13 +805,6 @@ async function create() {
725
805
  if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
726
806
  fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens, max 64 chars (e.g. "standup-notes").');
727
807
  }
728
- // An app with this slug already on disk means create is the wrong verb —
729
- // fail BEFORE registering, and never suggest deleting anything: the folder
730
- // may hold real, uncommitted work.
731
- const dupe = listLocalApps().find((a) => a.slug === slug);
732
- if (dupe) {
733
- fail("APP_EXISTS", `"${slug}" already exists on this machine at ${dupe.path}. Keep working on it there (cd "$(monty select ${slug})"); pick a different slug for a new app.`);
734
- }
735
808
  const name =
736
809
  flag("name") ??
737
810
  slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
@@ -777,15 +850,12 @@ async function create() {
777
850
  mkdirSync(dirname(target), { recursive: true });
778
851
 
779
852
  // DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
780
- // platform shell renders it; \`monty add page\` scaffolds a SPA the moment a
853
+ // platform shell renders it; \`monty page add\` scaffolds a SPA the moment a
781
854
  // bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
782
855
  // (\`--config-only\` stays accepted as the now-default no-op).
783
856
  if (!rest.includes("--spa")) {
784
- console.log(`create: ${slug} -> ${target} (config-only)`);
857
+ console.log(`create: ${slug} -> ${target} (config-only — the workspace holds the config)`);
785
858
  writeConfigOnlyScaffold(target, { appId, slug, name, icon });
786
- // The identity stamp is the app-root marker and identity source from
787
- // here on — the config file is just code.
788
- writeAppJson(target, { id: appId, slug, name, icon });
789
859
  // The user's brief lands at the top of AGENTS.md, same as SPA creates.
790
860
  const brief = flag("description");
791
861
  if (brief?.trim()) {
@@ -817,7 +887,7 @@ async function create() {
817
887
  }
818
888
  installSkills({ appDir: target });
819
889
  console.log(`created: ${target}`);
820
- console.log(`next: cd ${target} && monty install && monty dev`);
890
+ console.log(`next: cd ${target} && monty dev then \`monty schema set\` declares the tables`);
821
891
  return;
822
892
  }
823
893
 
@@ -827,7 +897,7 @@ async function create() {
827
897
  const templateDir = [
828
898
  join(pkgRoot, "template"),
829
899
  join(pkgRoot, "..", "template"),
830
- ].find((d) => existsSync(join(d, "monty.config.ts")));
900
+ ].find((d) => existsSync(join(d, "src", "main.tsx")));
831
901
  if (!templateDir) {
832
902
  fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
833
903
  }
@@ -854,16 +924,10 @@ async function create() {
854
924
  writeFileSync(gitignorePath, "node_modules/\ndist/\n.monty/\n.env.local\n.env\n");
855
925
  }
856
926
 
857
- // Stamp identity into the copied files. The id line is INSERTED (the
858
- // template ships without one only real creates have a server id).
859
- const configPath = join(target, "monty.config.ts");
860
- writeFileSync(
861
- configPath,
862
- readFileSync(configPath, "utf8")
863
- .replace(/^([ \t]*)slug: "[^"]*"/m, `$1id: "${appId}",\n$1slug: "${slug}"`)
864
- .replace(/name: "[^"]*"/, `name: "${name}"`)
865
- .replace(/icon: "[^"]*"/, `icon: "${icon}"`),
866
- );
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 });
867
931
  const pkgPath = join(target, "package.json");
868
932
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
869
933
  pkg.name = slug;
@@ -910,7 +974,7 @@ async function create() {
910
974
  }
911
975
 
912
976
  // Build tracking: the /new screen minted an id; recording it here lets
913
- // `monty deploy` resolve it and flips the UI into "agent is working" mode.
977
+ // `monty save` resolve it and flips the UI into "agent is working" mode.
914
978
  const buildId = flag("build");
915
979
  if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
916
980
  mkdirSync(join(target, ".monty"), { recursive: true });
@@ -937,20 +1001,35 @@ async function create() {
937
1001
  // The full app lifecycle goes through the CLI — agents never invoke pnpm,
938
1002
  // vite, or tsc directly. Same underlying tools, agent-shaped output, and the
939
1003
  // build-before-typecheck ordering handled for you.
940
- function installDeps() {
941
- const appDir = requireAppDir("install");
1004
+ function installDepsIn(appDir) {
942
1005
  const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
943
1006
  run(appDir, "install", [pm, "install"],
944
1007
  "Dependency install failed. Read the package manager error above; usually network or a bad package.json edit.");
945
1008
  ensureSdk(appDir);
946
1009
  }
947
1010
 
1011
+ function installDeps() {
1012
+ installDepsIn(requireAppDir("install"));
1013
+ }
1014
+
948
1015
  function buildApp() {
949
1016
  const appDir = requireAppDir("build");
950
1017
  run(appDir, "build", ["npx", "vite", "build"],
951
1018
  "The production build failed. Read the vite error above; it names the file to fix.");
952
1019
  }
953
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
+
954
1033
  function typecheckApp() {
955
1034
  const appDir = requireAppDir("typecheck");
956
1035
  // routeTree.gen.ts is generated by the build — without it tsc fails on a
@@ -991,7 +1070,7 @@ async function freePort(start) {
991
1070
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
992
1071
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
993
1072
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
994
- const MIN_SDK = "0.2.4";
1073
+ const MIN_SDK = "0.2.6";
995
1074
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
996
1075
 
997
1076
  function installedSdkVersion(appDir) {
@@ -1451,21 +1530,29 @@ async function dev() {
1451
1530
 
1452
1531
  installSkills({ appDir });
1453
1532
  ensureSdk(appDir);
1454
- // Registry-owned apps (stamped on the first configIgnored beat): the
1455
- // platform reads NOTHING from the config compile identity comes from
1456
- // the stamp and the compile is skipped entirely. The config file is just
1457
- // code the bundle imports; `schedule` (a code-door declaration the
1458
- // session cron ticker needs) is read literally, the same way the vite
1459
- // plugin reads `publicFns`.
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 {}
1541
+ // Identity comes from the .monty/app.json stamp (written by create and
1542
+ // connect) — the platform reads NOTHING from the config file, which is
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.
1460
1546
  const stamp = readAppJson(appDir);
1461
- const registryOwned = stamp?.registryOwned === true && typeof stamp?.slug === "string";
1462
- const meta = registryOwned
1463
- ? { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) }
1464
- : await compileConfig(appDir);
1547
+ if (typeof stamp?.slug !== "string" || !stamp.slug) {
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.");
1549
+ }
1550
+ const meta = { slug: stamp.slug, name: stamp.name, icon: stamp.icon };
1465
1551
  const cfg = loadConfig();
1466
1552
  const host = cfg?.host ?? DEFAULT_HOST;
1467
- // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
1468
- // 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.
1469
1556
  const configOnly = isConfigOnlyApp(appDir);
1470
1557
  // Auto-pick a free port (agents run several apps side by side); an
1471
1558
  // explicit --port is honored strictly.
@@ -1484,34 +1571,23 @@ async function dev() {
1484
1571
  stdio: ["ignore", "pipe", "pipe"],
1485
1572
  });
1486
1573
  } else {
1487
- 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\`)`);
1488
1575
  }
1489
1576
 
1490
1577
  let tunnelChild = null;
1491
1578
  let hbTimer = null;
1492
1579
  let touchTimer = null;
1493
- let cronTimer = null;
1494
1580
  let ended = false;
1495
1581
  let registeredOnce = false;
1496
- // Held-config warnings print once per drift episode, not every beat.
1497
- let driftAnnounced = false;
1498
- // The registry-owned notice prints once per session.
1582
+ // The schema-lives-in-the-workspace notice prints once per session.
1499
1583
  let configIgnoredAnnounced = false;
1500
1584
  const devStartedAt = Date.now();
1501
1585
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
1502
1586
  const buildFile = join(appDir, ".monty", "build");
1503
1587
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
1504
- // The session schema channel (manifest-less apps): heartbeats carry the
1505
- // compiled schema, and edits to monty.config.ts are re-compiled (softly)
1506
- // so schema changes reach the platform within one heartbeat.
1507
- // Registry-owned apps skip all of it — their schema lives behind the
1508
- // doors, and the local config copy follows the registry (auto-pull below).
1509
- let currentMeta = meta;
1510
1588
  // Once per registry change: the manifest hash we last tried to sync the
1511
1589
  // local config copy to (successful or refused — never loop on dirty).
1512
1590
  let syncAttemptedHash = null;
1513
- const configPath = join(appDir, "monty.config.ts");
1514
- let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
1515
1591
 
1516
1592
  // Advertise this session. The touch timer (not the platform heartbeat,
1517
1593
  // which starts minutes late or never when logged out) keeps updatedAt
@@ -1522,7 +1598,7 @@ async function dev() {
1522
1598
  version: 1,
1523
1599
  cli: CLI_VERSION,
1524
1600
  pid: process.pid,
1525
- vitePid: child.pid ?? null,
1601
+ vitePid: child?.pid ?? null,
1526
1602
  tunnelPid: null,
1527
1603
  port,
1528
1604
  slug: meta.slug,
@@ -1544,57 +1620,6 @@ async function dev() {
1544
1620
  });
1545
1621
  touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
1546
1622
 
1547
- // The session cron runner: the Live counterpart is a real Cloudflare Cron
1548
- // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1549
- // `schedule` entries against the UTC clock once per minute and invokes the
1550
- // fn through the same /__monty/fn runtime (x-monty-schedule marks the
1551
- // lane, so ctx.viewer matches Live exactly). Config edits hot-apply via
1552
- // currentMeta. Fire-and-forget: a failing cron fn prints its instruction
1553
- // here and never blocks the loop.
1554
- let lastCronMinute = null;
1555
- function cronTick() {
1556
- const sched = currentMeta?.schedule;
1557
- if (!sched || !loggedIn) return;
1558
- const now = new Date();
1559
- const minute = Math.floor(now.getTime() / 60_000);
1560
- if (minute === lastCronMinute) return;
1561
- lastCronMinute = minute;
1562
- for (const [fn, expr] of Object.entries(sched)) {
1563
- if (!cronMatches(expr, now)) continue;
1564
- console.log(`cron: "${expr}" → ${fn}() (UTC)`);
1565
- const t0 = Date.now();
1566
- fetch(`http://localhost:${port}/__monty/fn/${fn}`, {
1567
- method: "POST",
1568
- headers: { "content-type": "application/json", "x-monty-schedule": expr },
1569
- body: "{}",
1570
- }).then(async (r) => {
1571
- if (r.ok) {
1572
- console.log(`cron: ${fn} ok (${Date.now() - t0}ms)`);
1573
- } else {
1574
- const e = await r.json().catch(() => null);
1575
- console.log(`cron: ${fn} failed [${e?.code ?? r.status}] ${e?.fix ?? ""}`);
1576
- }
1577
- }).catch((e) => console.log(`cron: ${fn} unreachable — ${e?.message ?? e}`));
1578
- }
1579
- }
1580
- cronTimer = setInterval(cronTick, 20_000);
1581
-
1582
- async function refreshSchemaIfChanged() {
1583
- if (registryOwned) return; // the doors own the schema; nothing to push
1584
- try {
1585
- const m = statSync(configPath).mtimeMs;
1586
- if (m === configMtime) return;
1587
- configMtime = m;
1588
- const fresh = await compileConfig(appDir, { soft: true });
1589
- if (fresh) {
1590
- currentMeta = fresh;
1591
- console.log(
1592
- `schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1593
- );
1594
- }
1595
- } catch { /* transient fs hiccup — next beat retries */ }
1596
- }
1597
-
1598
1623
  async function clearDevSession(timeoutMs = 2000) {
1599
1624
  if (cfg?.key) {
1600
1625
  try {
@@ -1613,7 +1638,6 @@ async function dev() {
1613
1638
  ended = true;
1614
1639
  if (hbTimer) clearInterval(hbTimer);
1615
1640
  if (touchTimer) clearInterval(touchTimer);
1616
- if (cronTimer) clearInterval(cronTimer);
1617
1641
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1618
1642
  // vite is a direct child (no npx wrapper), so this actually kills it —
1619
1643
  // a bare SIGTERM from the desktop must never orphan vite on the port.
@@ -1628,7 +1652,6 @@ async function dev() {
1628
1652
  ended = true;
1629
1653
  if (hbTimer) clearInterval(hbTimer);
1630
1654
  if (touchTimer) clearInterval(touchTimer);
1631
- if (cronTimer) clearInterval(cronTimer);
1632
1655
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1633
1656
  try { child?.kill(); } catch { /* already gone */ }
1634
1657
  console.log(`dev-session: superseded — ${fix}`);
@@ -1639,7 +1662,6 @@ async function dev() {
1639
1662
 
1640
1663
  async function heartbeat(originUrl, { claim = false } = {}) {
1641
1664
  if (ended) return false; // shutdown already ran — no side effects
1642
- await refreshSchemaIfChanged();
1643
1665
  try {
1644
1666
  // Re-read the key EVERY beat: the desktop (or a fresh `monty login`)
1645
1667
  // may have replaced an expired key while this session runs — the
@@ -1659,23 +1681,9 @@ async function dev() {
1659
1681
  // owner. BOUNDED so a never-registering session (broken network)
1660
1682
  // can't steal the lock from a newer active session forever.
1661
1683
  claim: claim || (!registeredOnce && Date.now() - devStartedAt < 90_000),
1662
- name: currentMeta.name,
1663
- icon: currentMeta.icon,
1684
+ name: meta.name,
1685
+ icon: meta.icon,
1664
1686
  buildId,
1665
- schemaJson: currentMeta.schemaJson,
1666
- // App Manifest v2 (docs/manifest-v2.md) — present only for V2
1667
- // configs; lands only for manifest-less apps (the doors own the
1668
- // rest).
1669
- manifest: currentMeta.manifest,
1670
- // The CAS base for the manifest-less landing (see `monty schema
1671
- // pull`).
1672
- baseManifestHash:
1673
- currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
1674
- // The expose block rides the same compile as the schema — the dev
1675
- // visitor preview of a manifest-less app is gated on it.
1676
- exposure: currentMeta.exposure,
1677
- // Rules ride the same channel (manifest-less apps only).
1678
- rules: currentMeta.rules,
1679
1687
  }),
1680
1688
  signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
1681
1689
  });
@@ -1685,11 +1693,6 @@ async function dev() {
1685
1693
  stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
1686
1694
  return false;
1687
1695
  }
1688
- if (data?.code === "MANIFEST_DRIFT") {
1689
- // Remote schema edits (another agent, via the API) — the terminal
1690
- // the building agent is watching gets the fix, invariant #4.
1691
- console.log(`schema drift (remote changes):${data.summary ? `\n${data.summary}` : ""}`);
1692
- }
1693
1696
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
1694
1697
  // A dead key is a SIGNED-OUT session — advertise it so the desktop
1695
1698
  // (which owns the session) can surface sign-in instead of letting
@@ -1699,76 +1702,48 @@ async function dev() {
1699
1702
  }
1700
1703
  return false;
1701
1704
  }
1702
- if (data?.manifestDrift) {
1703
- // The session registered, but the config push was HELD: the stored
1704
- // schema changed since this checkout last synced (another editor).
1705
- if (!driftAnnounced) {
1706
- driftAnnounced = true;
1707
- console.log(`schema drift (remote changes):${data.manifestDrift.summary ? `\n${data.manifestDrift.summary}` : ""}`);
1708
- console.log(`config push held: ${data.manifestDrift.fix ?? "Run `monty schema pull`, merge, then save again."}`);
1709
- }
1710
- } else if (currentMeta.manifest !== undefined) {
1711
- driftAnnounced = false;
1712
- // This beat's manifest landed LIVE — the new CAS base.
1713
- try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
1714
- }
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 } : {};
1715
1709
  if (!registeredOnce) {
1716
1710
  registeredOnce = true;
1717
- sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
1711
+ sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now(), ...beatExtras });
1718
1712
  } else {
1719
- sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1713
+ sf.write({ loggedIn: true, lastHeartbeatAt: Date.now(), ...beatExtras });
1720
1714
  }
1721
- if (data?.configIgnored && !configIgnoredAnnounced) {
1715
+ if (!configIgnoredAnnounced) {
1722
1716
  configIgnoredAnnounced = true;
1723
- console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set <file>`.");
1724
- // Stamp registry ownership: from the next session on, the config
1725
- // compile is skipped entirely.
1726
- try {
1727
- writeAppJson(appDir, {
1728
- registryOwned: true,
1729
- slug: meta.slug,
1730
- ...(currentMeta.name ? { name: currentMeta.name } : {}),
1731
- ...(currentMeta.icon ? { icon: currentMeta.icon } : {}),
1732
- });
1733
- } catch { /* the stamp is a convenience; the beat decided */ }
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.");
1734
1718
  }
1735
- // The local config copy follows the registry: when the stored manifest
1736
- // moved (another editor, the schema door) and the local file is clean,
1737
- // regenerate it in place the sync of the mechanical copy is
1738
- // 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).
1739
1724
  if (
1740
1725
  typeof data?.manifestHash === "string" &&
1741
- existsSync(configPath) &&
1742
1726
  data.manifestHash !== readSchemaState(appDir)?.hash &&
1743
- data.manifestHash !== syncAttemptedHash
1727
+ data.manifestHash !== syncAttemptedHash &&
1728
+ !existsSync(join(appDir, "demo.json"))
1744
1729
  ) {
1745
1730
  syncAttemptedHash = data.manifestHash;
1746
1731
  try {
1747
- // Already in sync (fresh checkout, no state file yet)? Stamp the
1748
- // base and leave the file alone — regenerate only on real drift.
1749
- let alreadySynced = false;
1750
- try {
1751
- const compiled = await compileAppConfig(appDir);
1752
- if (compiled.manifest && manifestHash(compiled.manifest) === data.manifestHash) {
1753
- writeSchemaState(appDir, data.manifestHash);
1754
- alreadySynced = true;
1755
- }
1756
- } catch { /* uncompilable — let schemaPull's dirty check narrate */ }
1757
- if (!alreadySynced) {
1758
- await schemaPull({
1759
- appDir,
1760
- host,
1761
- key: loadConfig()?.key ?? cfg.key,
1762
- slug: meta.slug,
1763
- force: false,
1764
- compileAppConfig,
1765
- fail: (code, fix) => {
1766
- throw new Error(`${code} — ${fix}`);
1767
- },
1768
- });
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)})`);
1769
1744
  }
1770
1745
  } catch (e) {
1771
- 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`);
1772
1747
  }
1773
1748
  }
1774
1749
  return true;
@@ -1850,26 +1825,8 @@ async function dev() {
1850
1825
 
1851
1826
  if (configOnly) {
1852
1827
  sf.write({ state: "ready" });
1853
- console.log(
1854
- registryOwned
1855
- ? "ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`"
1856
- : "ready: config-only — saves land LIVE; the workspace renders them within seconds",
1857
- );
1828
+ console.log("ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`");
1858
1829
  void startDevSession();
1859
- if (!registryOwned) {
1860
- // Manifest-less apps only: config edits should land in seconds, not a
1861
- // heartbeat — watch the mtime and trigger an early beat (which
1862
- // recompiles + pushes). Registry-owned apps have nothing to push.
1863
- const cfgWatch = setInterval(() => {
1864
- try {
1865
- if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
1866
- } catch { /* transient fs hiccup */ }
1867
- }, 2000);
1868
- const stopWatch = () => clearInterval(cfgWatch);
1869
- process.on("SIGINT", stopWatch);
1870
- process.on("SIGTERM", stopWatch);
1871
- process.on("SIGHUP", stopWatch);
1872
- }
1873
1830
  }
1874
1831
 
1875
1832
  let announced = false;
@@ -2124,7 +2081,7 @@ function startTunnel(port, onUrlChange, onOutput) {
2124
2081
  });
2125
2082
  }
2126
2083
 
2127
- // ── monty add / components / docs ──────────────────────────────────────────
2084
+ // ── monty components / page ────────────────────────────────────────────────
2128
2085
  // Wraps the shadcn CLI behind the curated catalog: agents ask for a
2129
2086
  // capability by plain name ("kanban") and get the blessed, theme-compatible
2130
2087
  // implementation. Only catalog registries and core shadcn resolve.
@@ -2132,7 +2089,7 @@ function startTunnel(port, onUrlChange, onOutput) {
2132
2089
  function requireAppDir(cmd) {
2133
2090
  const appDir = findAppRoot(process.cwd());
2134
2091
  if (!appDir) {
2135
- fail("NOT_A_MONTY_APP", `Not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one then run \`monty ${cmd}\`.`);
2092
+ fail("NOT_A_MONTY_APP", `Not inside a Monty app. cd into the app's folder (\`monty connect <slug>\` pulls a copy into any folder), then run \`monty ${cmd}\`.`);
2136
2093
  }
2137
2094
  return appDir;
2138
2095
  }
@@ -2150,29 +2107,31 @@ function resolveComponent(name) {
2150
2107
  return name;
2151
2108
  }
2152
2109
 
2153
- // ── monty add page <name> ──────────────────────────────────────────────────
2110
+ // ── monty page add <name> ──────────────────────────────────────────────────
2154
2111
  // Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
2155
2112
  // use (config-only apps gain src/ + vite from the template — their
2156
- // monty.config.ts and AGENTS.md stay untouched), declares
2113
+ // AGENTS.md stays untouched), declares
2157
2114
  // `pages.<name> = { kind: "custom", path: "/<name>" }`, and writes the page
2158
2115
  // route. DECLARE-FIRST: on a workspace-owned app the entry lands through
2159
2116
  // the schema door BEFORE the code exists — a save carrying an undeclared
2160
2117
  // route refuses (DEPLOY_UNDECLARED_PAGE). The Shopify model: system pages
2161
2118
  // stay shell-rendered; only this page is the app's own code.
2162
2119
 
2163
- // Declare the page through the schema door. Returns "declared" | "already"
2164
- // (workspace-owned app) or "config" (manifest-less: the config file is
2165
- // 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".
2166
2122
  async function declarePageThroughDoor(appDir, pageName) {
2167
2123
  const slug = readSlug(appDir);
2168
2124
  const { host, key } = loadConfig();
2169
- 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.");
2170
2127
  const read = await fetch(`${host}/api/schema?slug=${slug}`, {
2171
2128
  headers: { authorization: `Bearer ${key}` },
2172
2129
  }).catch(() => null);
2173
2130
  const readBody = await read?.json().catch(() => null);
2174
- if (!read?.ok || !readBody?.ok || readBody.manifest === null) return "config";
2175
- 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: {} };
2176
2135
  const keyOf = (n) => n.toLowerCase().replace(/[^a-z0-9]/g, "");
2177
2136
  const declared = Object.keys(manifest.pages ?? {}).find((n) => keyOf(n) === keyOf(pageName));
2178
2137
  if (declared) {
@@ -2198,36 +2157,35 @@ async function declarePageThroughDoor(appDir, pageName) {
2198
2157
  if (!res.ok || !body?.ok) {
2199
2158
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Declaring the page through the schema door failed — check the connection and retry.");
2200
2159
  }
2201
- 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 */ }
2202
2166
  return "declared";
2203
2167
  }
2204
2168
 
2205
2169
  async function addPage(appDir, pageName) {
2206
2170
  if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
2207
- fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2171
+ fail("INVALID_PAGE", 'Usage: monty page add <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2208
2172
  }
2209
- const configPath = join(appDir, "monty.config.ts");
2210
- if (!existsSync(configPath)) {
2211
- 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.`);
2212
2176
  }
2213
- const config = readFileSync(configPath, "utf8");
2214
- const appName = config.match(/name: "([^"]*)"/)?.[1] ?? pageName;
2177
+ const appName = stamp.name ?? pageName;
2215
2178
  const routeFile = join(appDir, "src", "routes", `${pageName}.tsx`);
2216
2179
  if (existsSync(routeFile)) {
2217
2180
  fail("PAGE_EXISTS", `src/routes/${pageName}.tsx already exists. Edit it, or pick a different page name.`);
2218
2181
  }
2219
- if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
2220
- console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
2221
- }
2222
2182
  // Declare BEFORE any code exists: if the door refuses, nothing to clean up.
2223
2183
  const declared = await declarePageThroughDoor(appDir, pageName);
2224
- if (declared !== "config") {
2225
- console.log(
2226
- declared === "declared"
2227
- ? `declared: pages.${pageName} through the schema door — live in the workspace now`
2228
- : `declared: pages.${pageName} already in the workspace manifest`,
2229
- );
2230
- }
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
+ );
2231
2189
 
2232
2190
  // First custom page on a config-only app: bring in the SPA scaffold.
2233
2191
  if (isConfigOnlyApp(appDir)) {
@@ -2235,7 +2193,7 @@ async function addPage(appDir, pageName) {
2235
2193
  const templateDir = [
2236
2194
  join(pkgRoot, "template"),
2237
2195
  join(pkgRoot, "..", "template"),
2238
- ].find((d) => existsSync(join(d, "monty.config.ts")));
2196
+ ].find((d) => existsSync(join(d, "src", "main.tsx")));
2239
2197
  if (!templateDir) {
2240
2198
  fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
2241
2199
  }
@@ -2247,7 +2205,7 @@ async function addPage(appDir, pageName) {
2247
2205
  const base = basename(src);
2248
2206
  if (["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base)) return false;
2249
2207
  // The app keeps its own identity files.
2250
- if (["monty.config.ts", "AGENTS.md", "CLAUDE.md"].includes(base)) return false;
2208
+ if (["AGENTS.md", "CLAUDE.md"].includes(base)) return false;
2251
2209
  return true;
2252
2210
  },
2253
2211
  });
@@ -2289,9 +2247,19 @@ async function addPage(appDir, pageName) {
2289
2247
  // the only route beside __root.
2290
2248
  const starter = join(appDir, "src", "routes", "index.tsx");
2291
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
+ }
2292
2260
  appendFileSync(
2293
2261
  join(appDir, "AGENTS.md"),
2294
- `\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` declaration.\nDECLARE-FIRST: \`monty add page <name>\` declares the entry (through the\nschema door on workspace-owned apps) before writing the route — a save\ncarrying an undeclared route refuses. System pages (table views) stay\nshell-rendered — only build bespoke UI here. \`monty dev\` serves both.\nAfter every meaningful change verified in dev, run\n\`monty save "<what changed>"\` — it pushes the work to the cloud copy,\nlike \`git push main\`.\n\nEvery custom page opens with \`PageHeader\` from \`@montytools/sdk/ui\` —\nthe same bar the shell renders on system pages (page actions go in it as\n\`PageHeaderButton\`s, \`primary\` for the one main action). Also there:\n\`FloatingBar\`/\`FloatingBarButton\` and the Lyra table classes\n\`SURFACE\`/\`THEAD\`/\`TH\`/\`ROW\`/\`CHIP\`.\n`,
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`,
2295
2263
  );
2296
2264
  }
2297
2265
 
@@ -2340,30 +2308,6 @@ function ${pageComponentName(pageName)}() {
2340
2308
  }
2341
2309
  `);
2342
2310
  console.log(`page: src/routes/${pageName}.tsx`);
2343
-
2344
- // Manifest-less apps only: the config file is still their editor, so the
2345
- // entry registers there (workspace-owned apps declared through the door
2346
- // above — a config edit would be inert).
2347
- if (declared === "config") {
2348
- const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
2349
- let next = null;
2350
- if (/^(\s*)pages:\s*{/m.test(config)) {
2351
- next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
2352
- } else {
2353
- // No pages block: add one right before the config's closing `});`.
2354
- const close = config.lastIndexOf("});");
2355
- if (close !== -1) {
2356
- next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
2357
- }
2358
- }
2359
- if (next) {
2360
- writeFileSync(configPath, next);
2361
- console.log(`config: pages.${pageName} registered in monty.config.ts`);
2362
- } else {
2363
- console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
2364
- }
2365
- }
2366
-
2367
2311
  console.log(`added: custom page "${pageName}"`);
2368
2312
  console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
2369
2313
  }
@@ -2372,16 +2316,17 @@ function pageComponentName(pageName) {
2372
2316
  return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
2373
2317
  }
2374
2318
 
2375
- async function add() {
2376
- const appDir = requireAppDir("add");
2377
- const names = rest.filter((a) => !a.startsWith("--"));
2378
- if (names.length === 0) {
2379
- fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available (or `monty add page <name>` for a custom page).");
2380
- }
2381
- if (names[0] === "page") {
2382
- await addPage(appDir, names[1]);
2383
- return;
2319
+ // The page namespace: `monty page add <name>` scaffolds a bespoke page
2320
+ // (declare-first through the schema door).
2321
+ async function pageCmd() {
2322
+ const [sub, name] = rest.filter((a) => !a.startsWith("--"));
2323
+ if (sub !== "add") {
2324
+ fail("INVALID_PAGE", 'Usage: monty page add <name> — scaffold a custom page (e.g. `monty page add reports`).');
2384
2325
  }
2326
+ await addPage(requireAppDir("page"), name);
2327
+ }
2328
+
2329
+ async function addComponents(appDir, names) {
2385
2330
  const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
2386
2331
 
2387
2332
  // --overwrite so shadcn never halts on a per-file prompt (an aborted prompt
@@ -2423,31 +2368,50 @@ async function add() {
2423
2368
  console.log(`added: ${items.join(", ")} -> src/components (already themed; import and compose)`);
2424
2369
  }
2425
2370
 
2426
- function components() {
2427
- const query = rest.filter((a) => !a.startsWith("--")).join(" ").toLowerCase();
2371
+ // The component namespace: `monty components` lists/searches the curated
2372
+ // catalog, `components add <name...>` installs, `components docs <name>`
2373
+ // prints a component's source. (`search` is the explicit search subcommand;
2374
+ // a bare query searches too.)
2375
+ async function componentsCmd() {
2376
+ const args = rest.filter((a) => !a.startsWith("--"));
2377
+ const [sub, ...tail] = args;
2378
+ if (sub === "add") {
2379
+ if (tail.length === 0) {
2380
+ fail("NO_COMPONENT", "Usage: monty components add <name...> — `monty components` lists the catalog; core shadcn components install by bare name.");
2381
+ }
2382
+ await addComponents(requireAppDir("components"), tail);
2383
+ return;
2384
+ }
2385
+ if (sub === "docs") {
2386
+ if (!tail[0]) {
2387
+ fail("NO_COMPONENT", "Usage: monty components docs <name> — `monty components` lists the catalog.");
2388
+ }
2389
+ componentDocs(requireAppDir("components"), tail[0]);
2390
+ return;
2391
+ }
2392
+ listComponents((sub === "search" ? tail : args).join(" ").toLowerCase());
2393
+ }
2394
+
2395
+ function listComponents(query) {
2428
2396
  const entries = Object.entries(CATALOG).filter(
2429
2397
  ([name, { item, use }]) => !query || `${name} ${item} ${use}`.toLowerCase().includes(query),
2430
2398
  );
2431
2399
  const width = Math.max(...Object.keys(CATALOG).map((n) => n.length));
2432
2400
  const itemWidth = Math.max(...Object.values(CATALOG).map((c) => c.item.length));
2433
- console.log(`components: ${entries.length} curated (install with \`monty add <name>\`)`);
2401
+ console.log(`components: ${entries.length} curated (install with \`monty components add <name>\`)`);
2434
2402
  for (const [name, { item, use }] of entries) {
2435
2403
  console.log(` ${name.padEnd(width)} ${item.padEnd(itemWidth)} ${use}`);
2436
2404
  }
2437
2405
  console.log("core: any shadcn component installs by bare name (dialog, tabs, dropdown-menu, popover, tooltip, sheet, checkbox, textarea, ...)");
2438
- console.log("docs: `monty docs <name>` shows a component's source before installing");
2406
+ console.log("docs: `monty components docs <name>` shows a component's source before installing");
2439
2407
  }
2440
2408
 
2441
- async function docs() {
2442
- const appDir = requireAppDir("docs");
2443
- const name = rest.find((a) => !a.startsWith("--"));
2444
- if (!name) {
2445
- fail("NO_COMPONENT", "Usage: monty docs <name> — run `monty components` to see what's available.");
2446
- }
2409
+ function componentDocs(appDir, name) {
2447
2410
  run(appDir, "docs", ["pnpm", "dlx", "shadcn@latest", "view", resolveComponent(name)],
2448
2411
  `Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
2449
2412
  }
2450
2413
 
2414
+
2451
2415
  // ── monty secret ─────────────────────────────────────────────────────────
2452
2416
  // `monty secret set KEY [value]` / `monty secret rm KEY` — per-app
2453
2417
  // server-function secrets. The value goes to Cloudflare's per-script secrets
@@ -2455,12 +2419,11 @@ async function docs() {
2455
2419
  // readable back. Read from the arg, then a TTY prompt, then stdin (piping).
2456
2420
  async function secret() {
2457
2421
  const appDir = requireAppDir("secret");
2458
- // Identity comes from the stamp when it exists — no config compile for
2459
- // one slug read.
2460
2422
  const stamped = readAppJson(appDir);
2461
- const meta = typeof stamped?.slug === "string" && stamped.slug
2462
- ? { slug: stamped.slug }
2463
- : await compileConfig(appDir);
2423
+ if (typeof stamped?.slug !== "string" || !stamped.slug) {
2424
+ fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here first.");
2425
+ }
2426
+ const meta = { slug: stamped.slug };
2464
2427
  const config = loadConfig();
2465
2428
  if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
2466
2429
  const [sub, name] = rest.filter((a) => !a.startsWith("-"));
@@ -2498,10 +2461,11 @@ async function secret() {
2498
2461
  }
2499
2462
 
2500
2463
  // ── monty save ─────────────────────────────────────────────────────────────
2501
- // Push the working copy to the cloud copy, like `git push main`. `deploy` is
2502
- // the compat alias; both run the same pipeline (build + typecheck gate every
2503
- // save, then one multipart POST). The optional message rides the deploy meta
2504
- // so the platform can narrate the save later.
2464
+ // Push the working copy to the cloud copy, like `git push main` (build +
2465
+ // typecheck gate every save, then one multipart POST). A save ships
2466
+ // IMPLEMENTATION ONLY the app's config lives in the workspace and lands
2467
+ // through the doors; the config file is never compiled here. The optional
2468
+ // message rides the meta so the platform can narrate the save later.
2505
2469
  async function deploy() {
2506
2470
  const appDir = requireAppDir(command);
2507
2471
  ensureSdk(appDir);
@@ -2521,40 +2485,50 @@ async function deploy() {
2521
2485
  fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
2522
2486
  }
2523
2487
 
2524
- // 1) Compile monty.config.ts { slug, name, icon, schemaJson } using the
2525
- // app's OWN zod/sdk instances (esbuild-bundled, run in a subprocess).
2526
- console.log("compile: monty.config.ts");
2527
- const meta = await compileConfig(appDir);
2528
- console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
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).
2491
+ const stamp = readAppJson(appDir);
2492
+ if (typeof stamp?.slug !== "string" || !stamp.slug) {
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.");
2494
+ }
2495
+ const meta = {
2496
+ slug: stamp.slug,
2497
+ name: stamp.name ?? stamp.slug,
2498
+ icon: stamp.icon,
2499
+ };
2529
2500
  // `monty save "what changed"` — the message rides the meta for the
2530
2501
  // platform to render as this save's Activity row.
2531
2502
  if (message) meta.message = message;
2503
+ await migrateConfigToGen(appDir, { host: config.host, key: config.key, slug: meta.slug });
2532
2504
 
2533
- // Registry-owned apps ship IMPLEMENTATION ONLY: manifest, schema, rules,
2534
- // and exposure are door-owned (the server holds them regardless — not
2535
- // sending them keeps the wire honest). Code-door declarations
2536
- // (publicFns, schedule, pages, functions) still ride.
2537
- const registryOwned = readAppJson(appDir)?.registryOwned === true;
2538
- if (registryOwned) {
2539
- delete meta.schemaJson;
2540
- delete meta.manifest;
2541
- delete meta.exposure;
2542
- delete meta.rules;
2543
- }
2544
-
2545
- // CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
2546
- // the platform shell renders the app.
2505
+ // CONFIG-ONLY apps have no bundle the platform shell renders them; the
2506
+ // save carries the source snapshot only.
2547
2507
  const configOnly = isConfigOnlyApp(appDir);
2548
- if (!configOnly) {
2549
- // 2) Fail fast locally before any upload. Build FIRST — it also generates
2550
- // src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
2508
+ if (configOnly) {
2509
+ meta.configOnly = true;
2510
+ } else {
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
+ }
2551
2528
  run(appDir, "build", ["npx", "vite", "build"],
2552
2529
  "The production build failed. Read the vite error above; it names the file to fix.");
2553
2530
  run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
2554
2531
  "TypeScript errors above. Fix them in the listed files; `monty save` never uploads code that does not compile.");
2555
- } else if (!registryOwned && !meta.manifest) {
2556
- fail("MANIFEST_MISSING",
2557
- "This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
2558
2532
  }
2559
2533
 
2560
2534
  // 3) Multipart POST to the host.
@@ -2568,41 +2542,15 @@ async function deploy() {
2568
2542
  // 3a) Server functions (optional): bundle server/index.ts into one worker
2569
2543
  // script and ride the SAME deploy. The manifest (fns) goes in meta so
2570
2544
  // the router gates /__monty/fn/* without a lookup.
2571
- const serverBundle = await bundleServerFns(appDir, meta.schedule);
2572
- const publicFns = Array.isArray(meta.publicFns) ? meta.publicFns : [];
2573
- const scheduleEntries = Object.entries(meta.schedule ?? {});
2574
- if (!serverBundle && (publicFns.length > 0 || scheduleEntries.length > 0)) {
2575
- fail("SERVER_DIR_MISSING",
2576
- "monty.config.ts declares publicFns/schedule, but this app has no server/index.ts. Create it with the named exports, or remove the declarations.");
2577
- }
2545
+ const serverBundle = await bundleServerFns(appDir);
2578
2546
  if (serverBundle) {
2579
- for (const name of publicFns) {
2580
- if (!serverBundle.fns.includes(name)) {
2581
- fail("PUBLIC_FN_UNKNOWN",
2582
- `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.`);
2583
- }
2584
- }
2585
- for (const [name] of scheduleEntries) {
2586
- if (!serverBundle.fns.includes(name)) {
2587
- fail("SCHEDULE_UNKNOWN_FN",
2588
- `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.`);
2589
- }
2590
- }
2591
- // Wire compat: meta.fns stays the UNION (older hosts gate dispatch on
2592
- // it); meta.datasets is the additive split newer hosts classify with.
2593
- meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
2547
+ if (serverBundle.fns.length > 0) meta.fns = serverBundle.fns;
2594
2548
  if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
2595
2549
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
2596
2550
  const bundled = [];
2597
2551
  if (serverBundle.fns.length > 0) bundled.push(`${serverBundle.fns.length} function(s) (${serverBundle.fns.join(", ")})`);
2598
2552
  if (serverBundle.datasets.length > 0) bundled.push(`${serverBundle.datasets.length} dataset(s) (${serverBundle.datasets.join(", ")})`);
2599
2553
  console.log(`fns: bundled ${bundled.join(" + ")}`);
2600
- if (publicFns.length > 0) {
2601
- console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
2602
- }
2603
- if (scheduleEntries.length > 0) {
2604
- console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
2605
- }
2606
2554
  }
2607
2555
  // 3a½) Custom pages, by route-file convention (top-level src/routes/
2608
2556
  // files) — registered as fnsJson.pages so the shell's nav knows what this
@@ -2618,7 +2566,7 @@ async function deploy() {
2618
2566
  // only the minified bundle and the sole copy of the app's code is this
2619
2567
  // folder — delete it and the source is gone forever. The snapshot is what
2620
2568
  // `monty pull <slug>` restores on any machine, and the publish lands in
2621
- // the same version history as `monty commit`.
2569
+ // the same version history as `monty save`.
2622
2570
  let sourceHash = null;
2623
2571
  {
2624
2572
  const packed = packSource(appDir);
@@ -2633,13 +2581,6 @@ async function deploy() {
2633
2581
  console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
2634
2582
  }
2635
2583
  }
2636
- // V2 schema CAS: prove which stored manifest this checkout last synced,
2637
- // so an API/MCP edit made meanwhile surfaces as MANIFEST_DRIFT instead of
2638
- // being clobbered (fix: `monty schema pull`).
2639
- if (meta.manifest !== undefined) {
2640
- const state = readSchemaState(appDir);
2641
- if (state?.hash) meta.baseManifestHash = state.hash;
2642
- }
2643
2584
  form.set("monty", JSON.stringify(meta));
2644
2585
  let total = 0;
2645
2586
  for (const file of files) {
@@ -2650,7 +2591,7 @@ async function deploy() {
2650
2591
  }
2651
2592
  console.log(
2652
2593
  configOnly
2653
- ? `upload: config-only (manifest, no bundle) -> ${config.host}/api/deploy`
2594
+ ? `upload: config-only (source snapshot, no bundle) -> ${config.host}/api/deploy`
2654
2595
  : `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
2655
2596
  );
2656
2597
  const res = await fetch(`${config.host}/api/deploy`, {
@@ -2660,22 +2601,10 @@ async function deploy() {
2660
2601
  });
2661
2602
  const body = await res.json().catch(() => null);
2662
2603
  if (!res.ok || !body?.ok) {
2663
- if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
2664
- console.log(`schema drift (remote changes):\n${body.summary}`);
2665
- }
2666
2604
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
2667
2605
  }
2668
2606
  console.log(`origin: ${body.origin}`);
2669
2607
  console.log(`saved: ${body.url} (version ${body.version})`);
2670
- if (body.manifestDrift) {
2671
- // The code shipped; the config was HELD — the stored schema changed
2672
- // since this checkout last synced (another editor).
2673
- console.log(`schema drift (remote changes):${body.manifestDrift.summary ? `\n${body.manifestDrift.summary}` : ""}`);
2674
- console.log(`config push held: ${body.manifestDrift.fix ?? "Run `monty schema pull`, merge, then deploy again."}`);
2675
- } else if (meta.manifest !== undefined) {
2676
- // The manifest just published IS the new CAS base.
2677
- writeSchemaState(appDir, manifestHash(meta.manifest));
2678
- }
2679
2608
  // Stamp what was published — pull uses this to tell "unchanged since last
2680
2609
  // sync" from "locally modified".
2681
2610
  if (sourceHash) {
@@ -2694,7 +2623,7 @@ async function deploy() {
2694
2623
  // fn-worker's makeFnWorker; esbuild bundles it for workerd. node: imports are
2695
2624
  // rejected at compile time — Live runs on Cloudflare Workers, not Node.
2696
2625
  // Returns { code, fns, datasets } (per-folder export names) or null.
2697
- async function bundleServerFns(appDir, schedule) {
2626
+ async function bundleServerFns(appDir) {
2698
2627
  const serverEntry = join(appDir, "server", "index.ts");
2699
2628
  const datasetsEntry = join(appDir, "datasets", "index.ts");
2700
2629
  const hasServer = existsSync(serverEntry);
@@ -2705,14 +2634,11 @@ async function bundleServerFns(appDir, schedule) {
2705
2634
  mkdirSync(tmpDir, { recursive: true });
2706
2635
  const entry = join(tmpDir, "fn-worker-entry.mjs");
2707
2636
  const out = join(tmpDir, "fn-worker-out.mjs");
2708
- // The schedule map is baked into the bundle: Cloudflare's scheduled()
2709
- // hands back only the matching cron expression, so the worker needs the
2710
- // expression→fn mapping at runtime.
2711
2637
  writeFileSync(entry, [
2712
2638
  hasServer ? `import * as appFns from "../server/index";` : `const appFns = {};`,
2713
2639
  hasDatasets ? `import * as appDatasets from "../datasets/index";` : `const appDatasets = {};`,
2714
2640
  `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
2715
- `export default makeFnWorker({ ...appFns, ...appDatasets }, { schedule: ${JSON.stringify(schedule ?? {})} });`,
2641
+ `export default makeFnWorker({ ...appFns, ...appDatasets });`,
2716
2642
  ].join("\n"));
2717
2643
  // Fail the deploy if server code reaches for Node built-ins — a Worker
2718
2644
  // can't run them, and a silent runtime crash on Live is the worst outcome.
@@ -2777,25 +2703,6 @@ function discoverFnExports(serverEntry) {
2777
2703
  return [...names];
2778
2704
  }
2779
2705
 
2780
- // Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
2781
- // the session heartbeat's last good schema through transient config breakage.
2782
- async function compileConfig(appDir, { soft = false } = {}) {
2783
- try {
2784
- // Config-only apps (no SPA) always compile a manifest: the platform
2785
- // shell is their only renderer.
2786
- return await compileAppConfig(appDir, { forceManifest: isConfigOnlyApp(appDir) });
2787
- } catch (e) {
2788
- if (e instanceof CompileError) {
2789
- if (soft) {
2790
- console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
2791
- return null;
2792
- }
2793
- fail(e.code, e.fix);
2794
- }
2795
- throw e;
2796
- }
2797
- }
2798
-
2799
2706
  function run(cwd, label, argv, fixOnFail) {
2800
2707
  console.log(`${label}: ${argv.join(" ")}`);
2801
2708
  // stdin ignored: interactive prompts (e.g. shadcn's per-file overwrite
@@ -2821,58 +2728,6 @@ function walk(dir) {
2821
2728
  // UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
2822
2729
  // names (JAN, MON). Deliberately forgiving: an unparsable field simply never
2823
2730
  // matches locally — Cloudflare is the syntax authority at deploy, so a bad
2824
- // expression fails there with its own message.
2825
- 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 };
2826
- const CRON_DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
2827
-
2828
- function cronMatches(expr, date) {
2829
- const fields = String(expr).trim().split(/\s+/);
2830
- if (fields.length !== 5) return false;
2831
- const values = [
2832
- date.getUTCMinutes(),
2833
- date.getUTCHours(),
2834
- date.getUTCDate(),
2835
- date.getUTCMonth() + 1,
2836
- date.getUTCDay(),
2837
- ];
2838
- const bounds = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
2839
- return fields.every((field, i) => cronFieldMatches(field, values[i], bounds[i], i));
2840
- }
2841
-
2842
- function cronFieldMatches(field, value, [lo, hi], idx) {
2843
- const names = idx === 3 ? CRON_MONTHS : idx === 4 ? CRON_DAYS : null;
2844
- const num = (t) => {
2845
- const named = names?.[t.toLowerCase()];
2846
- if (named !== undefined) return named;
2847
- const n = Number(t);
2848
- return Number.isInteger(n) ? n : null;
2849
- };
2850
- for (const part of field.split(",")) {
2851
- const [rangeRaw, stepRaw] = part.split("/");
2852
- const step = stepRaw === undefined ? 1 : Number(stepRaw);
2853
- if (!Number.isInteger(step) || step < 1) continue;
2854
- let from;
2855
- let to;
2856
- if (rangeRaw === "*" || rangeRaw === "") {
2857
- from = lo;
2858
- to = hi;
2859
- } else if (rangeRaw.includes("-")) {
2860
- const [a, b] = rangeRaw.split("-");
2861
- from = num(a);
2862
- to = num(b);
2863
- } else {
2864
- from = num(rangeRaw);
2865
- to = stepRaw === undefined ? from : hi; // "5/10": from 5 to max, step 10
2866
- }
2867
- if (from === null || to === null || from > to) continue;
2868
- for (let v = from; v <= to; v += step) {
2869
- // day-of-week: cron accepts 7 for Sunday alongside 0
2870
- if (v === value || (idx === 4 && v === 7 && value === 0)) return true;
2871
- }
2872
- }
2873
- return false;
2874
- }
2875
-
2876
2731
  // ── monty data ─────────────────────────────────────────────────────────────
2877
2732
  // The agent verbs for OPERATING an app: read and write its records from any
2878
2733
  // terminal — no browser, no dev session. Auth is the mk_ key exchanged at
@@ -2917,10 +2772,7 @@ function resolveDataApp() {
2917
2772
  const root = findAppRoot(process.cwd());
2918
2773
  const slug = explicit ?? (root ? readSlugFromConfig(root) : null);
2919
2774
  if (!slug) {
2920
- fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
2921
- }
2922
- if (rest.includes("--studio")) {
2923
- fail("STUDIO_REMOVED", "The session sandbox is gone — every app has one set of records, and data verbs always target it. Drop --studio.");
2775
+ fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder.");
2924
2776
  }
2925
2777
  return slug;
2926
2778
  }
@@ -2994,7 +2846,7 @@ function flattenRow(doc) {
2994
2846
 
2995
2847
  function dataUsage() {
2996
2848
  console.log("usage: monty data <verb> [table] [flags] read/write an app's records");
2997
- 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)");
2998
2850
  console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
2999
2851
  console.log(" get <table> <id>");
3000
2852
  console.log(" insert <table> --data '<json|[json,…]>'");
@@ -3056,33 +2908,17 @@ async function data() {
3056
2908
  const [verb, table, id] = dataPositionals();
3057
2909
 
3058
2910
  if (verb === "schema") {
3059
- // Shape comes from the LOCAL source checkout (compiled through the real
3060
- // pipeline) the same monty.config.ts that defines what the app stores.
3061
- const explicit = flag("app");
3062
- const root = explicit
3063
- ? (listLocalApps().find((a) => a.slug === explicit || a.id === explicit)?.path ?? null)
3064
- : findAppRoot(process.cwd());
3065
- if (!root) {
3066
- fail("APP_NOT_LOCAL", explicit
3067
- ? `No local source for "${explicit}" on this machine — run \`monty pull ${explicit}\` first, or cd into the app folder.`
3068
- : "Not inside an app folder. Pass --app <slug> (needs the source pulled locally) or cd into the app.");
3069
- }
3070
- let meta;
3071
- try {
3072
- meta = await compileAppConfig(root);
3073
- } catch (e) {
3074
- if (e instanceof CompileError) fail(e.code, e.fix);
3075
- throw e;
3076
- }
3077
- const tables = meta.schemaJson?.tables ?? {};
3078
- if (table !== undefined) {
3079
- if (!tables[table]) {
3080
- fail("NO_SUCH_TABLE", `App "${meta.slug}" has no table "${table}". Tables: ${Object.keys(tables).join(", ") || "(none)"}.`);
3081
- }
3082
- printJson({ app: meta.slug, table, schema: tables[table] });
3083
- } else {
3084
- printJson({ app: meta.slug, tables });
3085
- }
2911
+ // Shape comes from the WORKSPACE the stored schema the platform
2912
+ // validates against (derived from the app's config).
2913
+ const app = resolveDataApp();
2914
+ const auth = await workspaceAuth();
2915
+ const result = await callRecords(
2916
+ "query",
2917
+ "schema",
2918
+ table !== undefined ? { app, table } : { app },
2919
+ auth,
2920
+ );
2921
+ printJson({ app, ...result });
3086
2922
  return;
3087
2923
  }
3088
2924
 
@@ -3267,7 +3103,7 @@ async function data() {
3267
3103
  // The CLI accepts concise filters, then stores the shell's existing
3268
3104
  // { filters, sort, hidden } ViewConfig shape unchanged.
3269
3105
 
3270
- const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name"]);
3106
+ const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name", "kanban"]);
3271
3107
 
3272
3108
  function viewPositionals() {
3273
3109
  const out = [];
@@ -3285,10 +3121,11 @@ function viewPositionals() {
3285
3121
  function viewsUsage() {
3286
3122
  console.log("usage: monty views <list|set|update|remove> <table> [name] [flags]");
3287
3123
  console.log(" list <table> [--app slug]");
3288
- console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--app slug]");
3289
- console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--app slug]");
3124
+ console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--kanban field|none] [--app slug]");
3125
+ console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--kanban field|none] [--app slug]");
3290
3126
  console.log(" remove <table> <name> [--app slug]");
3291
3127
  console.log("filter values: null = empty; scalar = exact; array = any listed value; {\"contains\":\"text\"}; {\"min\":0,\"max\":100}");
3128
+ console.log("kanban: the view renders as lanes over the select field's values; \"none\" makes it a table");
3292
3129
  console.log("example: monty views set leads \"Evaluate\" --filter '{\"pipelineId\":null}'");
3293
3130
  process.exit(1);
3294
3131
  }
@@ -3313,10 +3150,9 @@ function viewName(input) {
3313
3150
  async function viewContext(table) {
3314
3151
  const app = resolveDataApp();
3315
3152
  const auth = await workspaceAuth();
3316
- const manifest = await callConvex("query", "platform:appManifest", { slug: app }, auth);
3317
- if (!manifest) {
3318
- fail("NO_MANIFEST", `App "${app}" has no stored manifest, so it has no system table views.`);
3319
- }
3153
+ // Nothing stored yet is just an empty config the table lookup below
3154
+ // says what's actually missing.
3155
+ const manifest = (await callConvex("query", "platform:appManifest", { slug: app }, auth)) ?? { tables: {} };
3320
3156
  const tableSpec = manifest.tables?.[table];
3321
3157
  if (!tableSpec) {
3322
3158
  fail("NO_SUCH_TABLE", `App "${app}" has no table "${table}". Tables: ${Object.keys(manifest.tables ?? {}).join(", ") || "(none)"}.`);
@@ -3343,6 +3179,8 @@ function requestedViewConfig(fields) {
3343
3179
  sort: parseViewSort(flag("sort")),
3344
3180
  hidden: parseHiddenColumns(flag("hide")),
3345
3181
  };
3182
+ const kanban = parseKanbanFlag(flag("kanban"));
3183
+ if (kanban) Object.assign(config, kanban);
3346
3184
  return validateViewColumns(config, fields);
3347
3185
  } catch (error) {
3348
3186
  fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
@@ -3355,6 +3193,7 @@ function requestedViewPatch() {
3355
3193
  ...(rest.includes("--filter") ? { filters: normalizeViewFilters(parseJsonFlag("filter")) } : {}),
3356
3194
  ...(rest.includes("--sort") ? { sort: parseViewSort(flag("sort")) } : {}),
3357
3195
  ...(rest.includes("--hide") ? { hidden: parseHiddenColumns(flag("hide")) } : {}),
3196
+ ...(rest.includes("--kanban") ? { kanban: parseKanbanFlag(flag("kanban")) } : {}),
3358
3197
  };
3359
3198
  } catch (error) {
3360
3199
  fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
@@ -3405,7 +3244,7 @@ async function views() {
3405
3244
  }
3406
3245
  const patch = requestedViewPatch();
3407
3246
  if (nextName === name && Object.keys(patch).length === 0) {
3408
- fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, or --hide. Omitted properties stay unchanged.");
3247
+ fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, --hide, or --kanban. Omitted properties stay unchanged.");
3409
3248
  }
3410
3249
  const config = checkedViewConfig(mergeViewConfig(existing.config, patch), fields);
3411
3250
  await callConvex("mutation", "platform:saveView", { app, page: table, name: nextName, config }, auth);
@@ -3635,9 +3474,9 @@ if (command !== "dev" && command !== "logs" && command !== "support") {
3635
3474
  // The app's data half (tables, field algebra, metrics, settings, pages)
3636
3475
  // lives ONLY in the workspace. `monty schema [slug]` prints the stored
3637
3476
  // manifest as JSON (and stamps the CAS base); edit that JSON and
3638
- // `monty schema set <file|->` writes it back through the one landing —
3477
+ // `monty schema set '<json>'` (or `set -` piped) writes it back|->` writes it back through the one landing —
3639
3478
  // validated server-side, additive-only by default, CAS against what you
3640
- // read. `monty schema pull` (legacy) regenerates monty.config.ts.
3479
+ // read. `monty schema pull` regenerates the src/monty.gen.ts mirror.
3641
3480
  async function schemaCmd() {
3642
3481
  const verb = rest[0];
3643
3482
  const { host, key } = loadConfig() ?? {};
@@ -3646,42 +3485,38 @@ async function schemaCmd() {
3646
3485
 
3647
3486
  if (verb === "pull") {
3648
3487
  const dir = appDir ?? process.cwd();
3649
- let slug = rest.slice(1).find((a) => !a.startsWith("--"));
3488
+ const slug = rest.slice(1).find((a) => !a.startsWith("--")) ?? readSlug(dir);
3650
3489
  if (!slug) {
3651
- try {
3652
- slug = (await compileAppConfig(dir)).slug;
3653
- } catch {
3654
- fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
3655
- }
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).");
3656
3491
  }
3657
- await schemaPull({
3658
- appDir: dir, host, key, slug,
3659
- force: rest.includes("--force"),
3660
- compileAppConfig,
3661
- fail,
3662
- });
3492
+ const migrated = await migrateConfigToGen(dir, { host, key, slug });
3493
+ if (!migrated) await schemaPull({ appDir: dir, host, key, slug, fail });
3663
3494
  return;
3664
3495
  }
3665
3496
 
3666
3497
  if (verb === "set") {
3498
+ // The config is passed as JSON, never a file — nothing to leave behind
3499
+ // in the app folder. `-` reads stdin for configs too big for an arg.
3667
3500
  const target = rest[1];
3668
3501
  if (!target) {
3669
- fail("SCHEMA_USAGE", "Usage: monty schema set <file.json|-> [--allow-breaking] — the JSON is a full manifest (start from `monty schema`).");
3502
+ fail("SCHEMA_USAGE", "Usage: monty schema set '<json>' [--allow-breaking] — the app's full config as JSON (start from `monty schema`; pipe with `monty schema set -`).");
3670
3503
  }
3671
- let raw;
3672
- try {
3673
- raw = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
3674
- } catch {
3675
- fail("SCHEMA_USAGE", `Could not read ${target === "-" ? "stdin" : target}. Pass a manifest JSON file, or - for stdin.`);
3504
+ let raw = target;
3505
+ if (target === "-") {
3506
+ try {
3507
+ raw = readFileSync(0, "utf8");
3508
+ } catch {
3509
+ fail("SCHEMA_USAGE", "Could not read stdin. Pipe the config JSON in: `monty schema | <edit> | monty schema set -`.");
3510
+ }
3676
3511
  }
3677
3512
  let manifest;
3678
3513
  try {
3679
3514
  manifest = JSON.parse(raw);
3680
3515
  } catch {
3681
- fail("BAD_MANIFEST_JSON", "That is not valid JSON. Start from `monty schema` output, edit, and set the whole document back.");
3516
+ fail("BAD_CONFIG_JSON", "That is not valid JSON. Pass the whole config as one JSON argument (start from `monty schema` output), or pipe it with `monty schema set -`.");
3682
3517
  }
3683
3518
  const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
3684
- if (!slug) fail("INVALID_SLUG", "The manifest carries no slug and this is not an app folder — set `slug` in the JSON.");
3519
+ if (!slug) fail("INVALID_SLUG", "The config carries no slug and this is not an app folder — set `slug` in the JSON.");
3685
3520
  // CAS: prove which stored manifest this edit was based on (stamped by
3686
3521
  // the last `monty schema` read in this folder). Absent = trusting push.
3687
3522
  const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
@@ -3700,15 +3535,22 @@ async function schemaCmd() {
3700
3535
  if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
3701
3536
  console.log(`schema drift (remote changes):\n${body.summary}`);
3702
3537
  }
3703
- fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the manifest failed — is the Monty host reachable?");
3538
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the config failed — is the Monty host reachable?");
3539
+ }
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);
3704
3547
  }
3705
- if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3706
3548
  console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
3707
3549
  return;
3708
3550
  }
3709
3551
 
3710
3552
  // Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
3711
- // JSON (pipe it to a file, edit, `monty schema set` it back).
3553
+ // JSON (edit it, then `monty schema set` the whole document back).
3712
3554
  const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
3713
3555
  if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
3714
3556
  const res = await fetch(`${host}/api/schema?slug=${slug}`, {
@@ -3716,16 +3558,95 @@ async function schemaCmd() {
3716
3558
  });
3717
3559
  const body = await res.json().catch(() => null);
3718
3560
  if (!res.ok || !body?.ok) {
3719
- fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the manifest — check the connection and `monty login`.");
3561
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the config — check the connection and `monty login`.");
3720
3562
  }
3721
3563
  if (body.manifest === null) {
3722
- fail("NO_MANIFEST", `"${slug}" has no stored manifest yet it's a code-only app. Declare one by setting a full manifest JSON: monty schema set <file>.`);
3564
+ // Nothing stored yet is just an empty config hand it back so the
3565
+ // agent can fill it in and `monty schema set` it.
3566
+ console.error(`# ${slug} — empty config (nothing stored yet; "monty schema set" declares it)`);
3567
+ console.log(JSON.stringify({ slug, tables: {} }, null, 2));
3568
+ return;
3569
+ }
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);
3723
3576
  }
3724
- if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3725
- console.error(`# ${slug} — manifest hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3577
+ console.error(`# ${slug} — config hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3726
3578
  console.log(JSON.stringify(body.manifest, null, 2));
3727
3579
  }
3728
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
+
3729
3650
  switch (command) {
3730
3651
  case "login":
3731
3652
  await login();
@@ -3733,41 +3654,30 @@ switch (command) {
3733
3654
  case "create":
3734
3655
  await create();
3735
3656
  break;
3657
+ case "connect":
3658
+ await connect();
3659
+ break;
3736
3660
  case "pull":
3737
3661
  await pull();
3738
3662
  break;
3739
- case "commit":
3740
- fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
3741
- break;
3742
- case "log":
3743
- case "versions":
3663
+ case "history":
3744
3664
  await versionsLog();
3745
3665
  break;
3666
+ case "page":
3667
+ await pageCmd();
3668
+ break;
3746
3669
  case "dev":
3747
3670
  await dev();
3748
3671
  break;
3749
3672
  case "logs":
3750
3673
  await logs();
3751
3674
  break;
3752
- case "add":
3753
- await add();
3754
- break;
3755
3675
  case "components":
3756
- case "search":
3757
- components();
3758
- break;
3759
- case "docs":
3760
- await docs();
3676
+ await componentsCmd();
3761
3677
  break;
3762
3678
  case "current":
3763
3679
  current();
3764
3680
  break;
3765
- case "select":
3766
- select();
3767
- break;
3768
- case "apps":
3769
- apps();
3770
- break;
3771
3681
  case "skills":
3772
3682
  installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
3773
3683
  console.log("skills: up to date");
@@ -3781,8 +3691,10 @@ switch (command) {
3781
3691
  case "typecheck":
3782
3692
  typecheckApp();
3783
3693
  break;
3694
+ case "style":
3695
+ await styleCheck();
3696
+ break;
3784
3697
  case "save":
3785
- case "deploy":
3786
3698
  await deploy();
3787
3699
  break;
3788
3700
  case "data":
@@ -3791,6 +3703,9 @@ switch (command) {
3791
3703
  case "schema":
3792
3704
  await schemaCmd();
3793
3705
  break;
3706
+ case "public":
3707
+ await publicCmd();
3708
+ break;
3794
3709
  case "views":
3795
3710
  await views();
3796
3711
  break;
@@ -3800,29 +3715,43 @@ switch (command) {
3800
3715
  case "secret":
3801
3716
  await secret();
3802
3717
  break;
3718
+ case "help":
3719
+ case "--help":
3720
+ case "-h":
3721
+ printHelp();
3722
+ process.exit(0);
3723
+ break;
3803
3724
  default:
3804
- console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|schema|views|support|skills>");
3805
- console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
3806
- console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
3807
- console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
3808
- console.log(" log [slug] the app's source version history (one row per save)");
3809
- console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app's session, or attach to a running one (live data, auto-auth)");
3810
- console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, save results)");
3811
- console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
3812
- console.log(" components [query] list the curated component catalog");
3813
- console.log(" docs <name> view a component's source before installing");
3814
- console.log(" current which app folder am I in?");
3815
- console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
3816
- console.log(" apps list local apps (~/.monty/apps + legacy ~/Monty)");
3817
- console.log(" install install app dependencies");
3818
- console.log(" build production build (vite, via monty)");
3819
- console.log(" typecheck typecheck (builds first if needed)");
3820
- console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main` (build + typecheck gate it)");
3821
- console.log(" deploy alias of save");
3822
- console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
3823
- console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
3824
- console.log(" views <list|set|update|remove> <table> manage shared saved views on a system record page");
3825
- console.log(" support <status|enable|disable|submit> opt in and send agent-authored platform reports to Monty");
3826
- console.log(" skills install/refresh the agent build skill");
3725
+ printHelp();
3827
3726
  process.exit(command ? 1 : 0);
3828
3727
  }
3728
+
3729
+ function printHelp() {
3730
+ console.log("usage: monty <command> (`monty help` shows this)");
3731
+ console.log("");
3732
+ console.log("start here:");
3733
+ console.log(" login [--host <url>] sign in (opens your browser to authorize)");
3734
+ console.log(" connect <slug> [dir] pull a cloud app into any folder, ready for `monty dev`");
3735
+ console.log(" create <slug> [--name N] [--spa] register a brand-new app (config-only by default)");
3736
+ console.log(" dev [--port N] [--takeover] run the app's session — live data, the workspace follows it");
3737
+ console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main`");
3738
+ console.log(" logs [-n N] [-f] read/follow the running session's log");
3739
+ console.log("");
3740
+ console.log("toolbelt:");
3741
+ console.log(" current which app folder am I in?");
3742
+ console.log(" history [slug] saved-version history, one row per save (like `git log`)");
3743
+ console.log(" pull <slug> [--version H] [--force] restore a source snapshot into the managed home");
3744
+ console.log(" install / build / typecheck / style app lifecycle + the token-vocabulary lint");
3745
+ console.log(" data <verb> [table] [flags] read/write an app's records from the terminal");
3746
+ console.log(" schema [slug] | set <json|-> read/write the app's config, stored in the workspace (validated, CAS)");
3747
+ console.log(" views <list|set|update|remove> <table> manage shared saved views on a record page");
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");
3750
+ console.log(" support <status|enable|disable|submit> opt in to agent-authored platform reports");
3751
+ console.log(" skills install/refresh the agent build skill");
3752
+ console.log("");
3753
+ console.log("custom pages & components (apps with src/):");
3754
+ console.log(" page add <name> scaffold a custom (bespoke) page");
3755
+ console.log(" components [query] | search <q> the curated shadcn catalog — list or search it");
3756
+ console.log(" components add <name...> | docs <name> install curated components / read one's source first");
3757
+ }