@montytools/cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/monty.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // monty — the Monty platform CLI. Output is agent-shaped: structured
3
3
  // single-line events, no spinners, instruction-shaped errors, and a
4
- // deterministic final line (`deployed: …` / `error: …`).
4
+ // deterministic final line (`saved: …` / `error: …`).
5
5
 
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { createHash, randomBytes } from "node:crypto";
@@ -63,10 +63,30 @@ function flag(name) {
63
63
  }
64
64
 
65
65
  function fail(code, fix) {
66
+ // A failing save narrates itself to the desktop chip before exiting.
67
+ saveFailNote?.(code, fix);
68
+ saveFailNote = null;
66
69
  console.error(`error: [MontyError ${code}] Fix: ${fix}`);
67
70
  process.exit(1);
68
71
  }
69
72
 
73
+ // Set by deploy() while a save runs; fail() and the exit hook route the
74
+ // error instruction into .monty/save.json so the chip shows it.
75
+ let saveFailNote = null;
76
+
77
+ // The save state file the desktop tails: .monty/save.json narrates every
78
+ // save — agent-run saves in their own terminal included — so the chip can
79
+ // show Saving…/Saved/"Couldn't save" without parsing any output.
80
+ function writeSaveJson(appDir, state) {
81
+ try {
82
+ mkdirSync(join(appDir, ".monty"), { recursive: true });
83
+ writeFileSync(
84
+ join(appDir, ".monty", "save.json"),
85
+ JSON.stringify({ ...state, at: Date.now() }, null, 2) + "\n",
86
+ );
87
+ } catch { /* advisory — the chip just misses this save */ }
88
+ }
89
+
70
90
  // ── Profiles & the .montyrc directory pin ──────────────────────────────────
71
91
  // One key PER HOST (like kubectl contexts): logging into the local platform
72
92
  // host never clobbers the prod key. Which host a command targets resolves,
@@ -257,6 +277,22 @@ function readMarker(path) {
257
277
  }
258
278
  }
259
279
 
280
+ // True when the installed marker is BEHIND this CLI. Ordering matters: two
281
+ // CLI versions share one machine (repo link vs npm global, bundle vs
282
+ // global), and an equality check makes them overwrite each other's skills
283
+ // on every alternating run — the older one must never win.
284
+ function markerOutdated(marker) {
285
+ if (!marker) return true;
286
+ const a = marker.split(".").map(Number);
287
+ const b = CLI_VERSION.split(".").map(Number);
288
+ if (a.some(Number.isNaN) || b.some(Number.isNaN)) return marker !== CLI_VERSION;
289
+ for (let i = 0; i < 3; i++) {
290
+ if ((a[i] || 0) < (b[i] || 0)) return true;
291
+ if ((a[i] || 0) > (b[i] || 0)) return false;
292
+ }
293
+ return false;
294
+ }
295
+
260
296
  function skillsCliAdd({ global = false, cwd = undefined } = {}) {
261
297
  const args = ["-y", "skills", "add", SKILLS_SRC, "-y", "--copy", ...(global ? ["-g"] : []), ...SKILL_AGENTS];
262
298
  const res = spawnSync("npx", args, { cwd, stdio: "ignore", timeout: 120_000 });
@@ -285,7 +321,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
285
321
  if (process.env.MONTY_NO_SKILLS) return;
286
322
  try {
287
323
  let changed = false;
288
- if (force || readMarker(GLOBAL_SKILLS_MARKER) !== CLI_VERSION) {
324
+ if (force || markerOutdated(readMarker(GLOBAL_SKILLS_MARKER))) {
289
325
  if (!skillsCliAdd({ global: true })) manualInstall(null);
290
326
  mkdirSync(CONFIG_DIR, { recursive: true });
291
327
  writeFileSync(GLOBAL_SKILLS_MARKER, CLI_VERSION + "\n");
@@ -293,7 +329,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
293
329
  }
294
330
  if (appDir) {
295
331
  const marker = join(appDir, ".agents", "skills", "monty-build", "VERSION");
296
- if (force || readMarker(marker) !== CLI_VERSION) {
332
+ if (force || markerOutdated(readMarker(marker))) {
297
333
  if (!skillsCliAdd({ cwd: appDir })) manualInstall(appDir);
298
334
  mkdirSync(dirname(marker), { recursive: true });
299
335
  writeFileSync(marker, CLI_VERSION + "\n");
@@ -310,17 +346,42 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
310
346
  // ── monty current / select / apps ───────────────────────────────────────────
311
347
  // Folder management users never think about: every app lives in ~/Monty,
312
348
  // `current` says where you are, `select` prints the folder for cd $(...).
349
+ // The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
350
+ // create and pull) or, for older folders, monty.config.ts.
351
+ function isAppRoot(dir) {
352
+ return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
353
+ }
354
+
313
355
  function findAppRoot(start) {
314
356
  let d = start;
315
357
  for (;;) {
316
- if (existsSync(join(d, "monty.config.ts"))) return d;
358
+ if (isAppRoot(d)) return d;
317
359
  const parent = dirname(d);
318
360
  if (parent === d) return null;
319
361
  d = parent;
320
362
  }
321
363
  }
322
364
 
365
+ // The identity stamp: { id, slug, name, icon, registryOwned? } — the app's
366
+ // durable identity on this machine, independent of the config file.
367
+ function readAppJson(dir) {
368
+ try {
369
+ return JSON.parse(readFileSync(join(dir, ".monty", "app.json"), "utf8"));
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ function writeAppJson(dir, patch) {
376
+ mkdirSync(join(dir, ".monty"), { recursive: true });
377
+ const next = { ...(readAppJson(dir) ?? {}), ...patch };
378
+ writeFileSync(join(dir, ".monty", "app.json"), JSON.stringify(next, null, 2) + "\n");
379
+ return next;
380
+ }
381
+
323
382
  function readSlugFromConfig(dir) {
383
+ const stamped = readAppJson(dir)?.slug;
384
+ if (typeof stamped === "string" && stamped) return stamped;
324
385
  try {
325
386
  return /slug:\s*"([^"]+)"/.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
326
387
  } catch {
@@ -329,6 +390,8 @@ function readSlugFromConfig(dir) {
329
390
  }
330
391
 
331
392
  function readIdFromConfig(dir) {
393
+ const stamped = readAppJson(dir)?.id;
394
+ if (typeof stamped === "string" && stamped) return stamped;
332
395
  try {
333
396
  return /^[ \t]*id:\s*"([^"]+)"/m.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
334
397
  } catch {
@@ -340,7 +403,7 @@ function scanAppsHome(root) {
340
403
  if (!existsSync(root)) return [];
341
404
  return readdirSync(root)
342
405
  .map((name) => join(root, name))
343
- .filter((p) => existsSync(join(p, "monty.config.ts")))
406
+ .filter((p) => isAppRoot(p))
344
407
  .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
345
408
  }
346
409
 
@@ -391,11 +454,10 @@ function apps() {
391
454
  }
392
455
 
393
456
  // Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
394
- // tar.gz buffer + its sha256 — the snapshot unit `monty commit` and deploys
395
- // both upload. gzip runs with -n (no embedded timestamp) so an UNCHANGED
396
- // tree packs to identical bytes — that's what makes "nothing to commit"
397
- // detectable by hash. Returns null when packing fails; { tooLarge } past
398
- // the 10MB cap.
457
+ // tar.gz buffer + its sha256 — the snapshot unit every `monty save`
458
+ // uploads. gzip runs with -n (no embedded timestamp) so an UNCHANGED tree
459
+ // packs to identical bytes — the hash doubles as the change detector.
460
+ // Returns null when packing fails; { tooLarge } past the 10MB cap.
399
461
  function packSource(appDir) {
400
462
  mkdirSync(join(appDir, ".monty"), { recursive: true });
401
463
  const srcTar = join(appDir, ".monty", "source-upload.tar.gz");
@@ -417,60 +479,12 @@ function packSource(appDir) {
417
479
  }
418
480
 
419
481
  function readSlug(appDir) {
420
- try {
421
- return /slug:\s*"([^"]+)"/.exec(readFileSync(join(appDir, "monty.config.ts"), "utf8"))?.[1] ?? null;
422
- } catch {
423
- return null;
424
- }
425
- }
426
-
427
- // ── monty commit ───────────────────────────────────────────────────────────
428
- // Version the app's source WITHOUT publishing: pack the tree, upload it as
429
- // one line of history. Git commit with everything stripped except "track
430
- // versions" — no branches, no diffs, no local repo; history lives in the
431
- // workspace and survives this folder.
432
- async function commit() {
433
- const appDir = requireAppDir("commit");
434
- const slug = readSlug(appDir);
435
- if (!slug) fail("NO_SLUG", "monty.config.ts has no slug — fix the config, then retry.");
436
- const { host, key } = loadConfig();
437
- if (!key) fail("NOT_LOGGED_IN", "Committing stores the snapshot in your workspace. Run `monty login` first.");
438
- const mIdx = rest.indexOf("-m");
439
- const message = flag("message") ?? (mIdx >= 0 ? rest[mIdx + 1] : undefined) ?? "checkpoint";
440
- const packed = packSource(appDir);
441
- if (packed === null) fail("PACK_FAILED", "Packing the source failed (tar error). Retry; check the folder is readable.");
442
- if (packed.tooLarge) fail("SOURCE_TOO_LARGE", "The source tree exceeds 10 MB (node_modules/dist excluded). Remove large assets, then retry.");
443
- try {
444
- const stamp = JSON.parse(readFileSync(join(appDir, ".monty", "source.json"), "utf8"));
445
- if (stamp.hash === packed.hash) {
446
- console.log(`nothing to commit — source unchanged since ${packed.hash.slice(0, 7)}`);
447
- return;
448
- }
449
- } catch {
450
- /* no stamp yet — first commit from this folder */
451
- }
452
- const form = new FormData();
453
- form.set("monty", JSON.stringify({ slug, hash: packed.hash, message }));
454
- form.set("source", new Blob([packed.buf]), "source.tar.gz");
455
- const res = await fetch(`${host}/api/source`, {
456
- method: "POST",
457
- headers: { authorization: `Bearer ${key}` },
458
- body: form,
459
- });
460
- const body = await res.json().catch(() => null);
461
- if (!res.ok || !body?.ok) {
462
- fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Uploading the snapshot failed — check the connection and retry.");
463
- }
464
- writeFileSync(
465
- join(appDir, ".monty", "source.json"),
466
- JSON.stringify({ hash: packed.hash, syncedAt: Date.now() }) + "\n",
467
- );
468
- console.log(`committed: ${packed.hash.slice(0, 7)} "${message}" (${(packed.buf.byteLength / 1024).toFixed(0)} KB)`);
482
+ return readSlugFromConfig(appDir);
469
483
  }
470
484
 
471
485
  // ── monty log ──────────────────────────────────────────────────────────────
472
- // The app's version history, newest first. Commits and publishes share one
473
- // timeline. (Not `monty logs` — that tails the dev shell.)
486
+ // The app's version history, newest first one row per `monty save`.
487
+ // (Not `monty logs` — that tails the dev shell.)
474
488
  async function versionsLog() {
475
489
  const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
476
490
  if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
@@ -484,19 +498,19 @@ async function versionsLog() {
484
498
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not list versions — check the connection and `monty login`.");
485
499
  }
486
500
  if (body.versions.length === 0) {
487
- console.log(`no versions of "${slug}" yet — \`monty commit\` or a publish creates the first one.`);
501
+ console.log(`no versions of "${slug}" yet — \`monty save\` creates the first one.`);
488
502
  return;
489
503
  }
490
504
  for (const v of body.versions) {
491
505
  const when = new Date(v.createdAt).toISOString().slice(0, 16).replace("T", " ");
492
- console.log(`${v.hash.slice(0, 7)} ${when} ${v.published ? "[published] " : ""}${v.message}`);
506
+ console.log(`${v.hash.slice(0, 7)} ${when} ${v.message}`);
493
507
  }
494
508
  console.log(`\nrestore one: monty pull ${slug} --version <hash> [--force]`);
495
509
  }
496
510
 
497
511
  // ── monty pull ─────────────────────────────────────────────────────────────
498
- // Restore an app's published source snapshot onto this machine. Every
499
- // `monty deploy`/publish uploads the source tree beside the bundle; pull is
512
+ // Restore an app's saved source snapshot onto this machine. Every
513
+ // `monty save` uploads the source tree beside the bundle; pull is
500
514
  // how a second machine (or one that lost the folder) gets the code back.
501
515
  // Refuses to touch an existing folder without --force — it may hold
502
516
  // unpublished work the snapshot would destroy.
@@ -541,11 +555,11 @@ async function pull() {
541
555
  expectedHash = matches[0].hash;
542
556
  downloadUrl = `${host}/api/source?slug=${slug}&hash=${expectedHash}`;
543
557
  } else if (!app.sourceHash) {
544
- fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each publish and \`monty commit\`. Run either once from the machine that has the source, then pull works everywhere.`);
558
+ fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each \`monty save\`. Run it once from the machine that has the source, then pull works everywhere.`);
545
559
  }
546
560
  const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
547
561
  if (existsSync(target) && !rest.includes("--force")) {
548
- fail("DIR_EXISTS", `${target} already exists and may hold unpublished work. Compare it with the published version first; re-run with --force to REPLACE it with the snapshot.`);
562
+ fail("DIR_EXISTS", `${target} already exists and may hold unsaved work. Compare it with the saved version first; re-run with --force to REPLACE it with the snapshot.`);
549
563
  }
550
564
 
551
565
  console.log(`pull: ${slug}${versionFlag ? ` @ ${expectedHash.slice(0, 7)}` : ""} <- ${host}`);
@@ -559,7 +573,7 @@ async function pull() {
559
573
  const buf = Buffer.from(await res.arrayBuffer());
560
574
  const hash = createHash("sha256").update(buf).digest("hex");
561
575
  if (hash !== expectedHash) {
562
- fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, republish the app from a machine that has the source.");
576
+ fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
563
577
  }
564
578
 
565
579
  // Extract into a staging folder, then move into place — a failed extract
@@ -573,7 +587,7 @@ async function pull() {
573
587
  rmSync(tarFile, { force: true });
574
588
  if (untar.status !== 0) {
575
589
  rmSync(staging, { recursive: true, force: true });
576
- fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, republish the app.");
590
+ fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
577
591
  }
578
592
  if (existsSync(target)) rmSync(target, { recursive: true, force: true });
579
593
  renameSync(staging, target);
@@ -593,6 +607,14 @@ async function pull() {
593
607
  join(target, ".monty", "source.json"),
594
608
  JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
595
609
  );
610
+ // Re-stamp identity (the snapshot excludes .monty/): the workspace row is
611
+ // the authority for id/name/icon.
612
+ writeAppJson(target, {
613
+ ...(app.id ? { id: app.id } : {}),
614
+ slug,
615
+ ...(app.name ? { name: app.name } : {}),
616
+ ...(app.icon ? { icon: app.icon } : {}),
617
+ });
596
618
  console.log(`pulled: ${target}`);
597
619
  console.log("next: `monty install`, then `monty dev`.");
598
620
  }
@@ -605,20 +627,56 @@ function isConfigOnlyApp(appDir) {
605
627
  return !existsSync(join(appDir, "index.html"));
606
628
  }
607
629
 
630
+ // Literal read of the config's `schedule` block ({ fn: "cron expr" }) —
631
+ // the same light-touch parse the SDK's vite plugin uses for publicFns.
632
+ // Registry-owned sessions read it this way so the cron ticker works
633
+ // without a config compile.
634
+ function readScheduleLiteral(appDir) {
635
+ try {
636
+ const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
637
+ const block = /schedule:\s*{([^}]*)}/m.exec(src)?.[1];
638
+ if (!block) return undefined;
639
+ const out = {};
640
+ for (const m of block.matchAll(/["']?([a-zA-Z][a-zA-Z0-9_]*)["']?\s*:\s*"([^"]+)"/g)) {
641
+ out[m[1]] = m[2];
642
+ }
643
+ return Object.keys(out).length > 0 ? out : undefined;
644
+ } catch {
645
+ return undefined;
646
+ }
647
+ }
648
+
649
+ // The code half's custom pages, by file convention: every TOP-LEVEL route
650
+ // file in src/routes/ is a page (nested routes — dot-separated names — are a
651
+ // page's inner paths). `__root`/`index` are the SPA's own plumbing, never
652
+ // pages. Saves register these as fnsJson.pages; a running session serves
653
+ // every declared page directly.
654
+ function discoverPages(appDir) {
655
+ const routesDir = join(appDir, "src", "routes");
656
+ if (!existsSync(routesDir)) return [];
657
+ return readdirSync(routesDir)
658
+ .filter((f) => /\.(tsx|jsx)$/.test(f))
659
+ .map((f) => f.replace(/\.(tsx|jsx)$/, ""))
660
+ .filter((name) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name) && name !== "index")
661
+ .sort()
662
+ .map((name) => ({ name, path: `/${name}` }));
663
+ }
664
+
608
665
  const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
609
666
 
610
- The entire app is \`monty.config.ts\`: tables (zod), derived fields
611
- (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`, \`settings\`, and \`pages\`.
612
- The Monty platform renders it — there is no src/, no React, no build.
667
+ The app is rendered by the Monty platform from its WORKSPACE manifest —
668
+ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
669
+ \`settings\`, and \`pages\`. There is no src/, no React, no build.
613
670
 
614
- - Edit monty.config.ts, save; a running \`monty dev\` pushes the change to the
615
- Studio within seconds (watch the terminal for instruction-shaped errors).
671
+ - The schema lives in the workspace, and its door is the schema API:
672
+ read it with \`monty schema\`, change it with \`monty schema set <file|->\`
673
+ (validated, CAS-guarded, live within seconds). monty.config.ts edits do
674
+ NOT change a workspace-owned app's schema.
616
675
  - Formulas are strings in the Monty expression grammar, e.g.
617
676
  \`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
618
677
  ABOVE the formula and \`metrics.<name>\` are in scope.
619
- - \`monty deploy\` publishes the config to the workspace (no bundle).
620
- - Need a bespoke page later? \`monty add page\` upgrades this app with a SPA
621
- scaffold; the config keeps working unchanged.
678
+ - Need a bespoke page later? \`monty add page\` declares it and upgrades this
679
+ app with a SPA scaffold; \`monty save\` ships the code.
622
680
  `;
623
681
 
624
682
  function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
@@ -718,6 +776,9 @@ async function create() {
718
776
  if (!rest.includes("--spa")) {
719
777
  console.log(`create: ${slug} -> ${target} (config-only)`);
720
778
  writeConfigOnlyScaffold(target, { appId, slug, name, icon });
779
+ // The identity stamp is the app-root marker and identity source from
780
+ // here on — the config file is just code.
781
+ writeAppJson(target, { id: appId, slug, name, icon });
721
782
  // The user's brief lands at the top of AGENTS.md, same as SPA creates.
722
783
  const brief = flag("description");
723
784
  if (brief?.trim()) {
@@ -777,7 +838,7 @@ async function create() {
777
838
  // hard-excludes the real name from tarballs); the in-repo template has the
778
839
  // real file. Normalize, and backfill for bundles that carried neither —
779
840
  // without a .gitignore, tailwind v4's content scan includes .monty/ and
780
- // full-reloads Studio on every dev.json touch.
841
+ // full-reloads the session on every dev.json touch.
781
842
  const gitignorePath = join(target, ".gitignore");
782
843
  if (existsSync(join(target, "gitignore"))) {
783
844
  renameSync(join(target, "gitignore"), gitignorePath);
@@ -805,6 +866,9 @@ async function create() {
805
866
  htmlPath,
806
867
  readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
807
868
  );
869
+ // The identity stamp is the app-root marker and identity source from
870
+ // here on — the config file is just code the bundle imports.
871
+ writeAppJson(target, { id: appId, slug, name, icon });
808
872
 
809
873
  // The user's brief (--description, e.g. from the desktop's create dialog)
810
874
  // goes to the TOP of AGENTS.md — agent harnesses can't be handed an initial
@@ -920,7 +984,7 @@ async function freePort(start) {
920
984
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
921
985
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
922
986
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
923
- const MIN_SDK = "0.2.0";
987
+ const MIN_SDK = "0.2.2";
924
988
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
925
989
 
926
990
  function installedSdkVersion(appDir) {
@@ -1200,12 +1264,12 @@ function printAttach(appDir, s) {
1200
1264
  const beat = typeof s.lastHeartbeatAt === "number" ? Math.round((Date.now() - s.lastHeartbeatAt) / 1000) : null;
1201
1265
  console.log(
1202
1266
  beat !== null && beat > Math.round(DEV_JSON_STALE_MS / 1000)
1203
- ? `state: online (no heartbeat for ${beat}s — Studio may show offline)`
1267
+ ? `state: online (no heartbeat for ${beat}s — the workspace may show the session offline)`
1204
1268
  : `state: online (heartbeat ${beat ?? "?"}s ago)`,
1205
1269
  );
1206
- if (s.studioUrl) console.log(`studio: ${s.studioUrl} — your app runs there while this is up; click Publish to go Live`);
1270
+ if (s.host && s.slug) console.log(`app: ${s.host}/apps/${s.slug} — your app runs there while this is up`);
1207
1271
  } else {
1208
- console.log("state: ready (registering with the workspace — the Studio link appears on the first successful heartbeat)");
1272
+ console.log("state: ready (registering with the workspace — the app link appears on the first successful heartbeat)");
1209
1273
  }
1210
1274
  }
1211
1275
  console.log("logs: `monty logs -f` follows output; `monty logs -n 100` shows recent lines");
@@ -1327,11 +1391,11 @@ async function sweepOrphans(s) {
1327
1391
  }
1328
1392
 
1329
1393
  // ── monty dev ──────────────────────────────────────────────────────────────
1330
- // Puts the app in Studio: vite locally + a Cloudflare quick tunnel registered
1331
- // as the app's STUDIO channel, so workspace admins see the app (HMR included)
1332
- // at usemonty.dev while it runs. Data is #dev-sandboxed automatically (vite
1333
- // dev build). The heartbeat doubles as the publish poll: when an owner
1334
- // clicks Publish in the workspace, this process builds + uploads to Live.
1394
+ // Runs the app's session: vite locally + a Cloudflare quick tunnel
1395
+ // registered as the app's session channel, so workspace admins see the
1396
+ // app (HMR included) at usemonty.dev while it runs. One data namespace:
1397
+ // the session reads and writes the app's REAL records editing is the
1398
+ // change going live. `monty save` ships code.
1335
1399
  async function dev() {
1336
1400
  const appDir = requireAppDir("dev");
1337
1401
 
@@ -1380,11 +1444,21 @@ async function dev() {
1380
1444
 
1381
1445
  installSkills({ appDir });
1382
1446
  ensureSdk(appDir);
1383
- const meta = await compileConfig(appDir);
1447
+ // Registry-owned apps (stamped on the first configIgnored beat): the
1448
+ // platform reads NOTHING from the config compile — identity comes from
1449
+ // the stamp and the compile is skipped entirely. The config file is just
1450
+ // code the bundle imports; `schedule` (a code-door declaration the
1451
+ // session cron ticker needs) is read literally, the same way the vite
1452
+ // plugin reads `publicFns`.
1453
+ const stamp = readAppJson(appDir);
1454
+ const registryOwned = stamp?.registryOwned === true && typeof stamp?.slug === "string";
1455
+ const meta = registryOwned
1456
+ ? { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) }
1457
+ : await compileConfig(appDir);
1384
1458
  const cfg = loadConfig();
1385
1459
  const host = cfg?.host ?? DEFAULT_HOST;
1386
1460
  // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
1387
- // compile + push — the platform shell renders the Studio channel.
1461
+ // compile + push — the platform shell renders the app.
1388
1462
  const configOnly = isConfigOnlyApp(appDir);
1389
1463
  // Auto-pick a free port (agents run several apps side by side); an
1390
1464
  // explicit --port is honored strictly.
@@ -1407,23 +1481,30 @@ async function dev() {
1407
1481
  }
1408
1482
 
1409
1483
  let tunnelChild = null;
1410
- let pubChild = null;
1411
1484
  let hbTimer = null;
1412
1485
  let touchTimer = null;
1413
1486
  let cronTimer = null;
1414
- let publishing = false;
1415
1487
  let ended = false;
1416
1488
  let registeredOnce = false;
1489
+ // Held-config warnings print once per drift episode, not every beat.
1490
+ let driftAnnounced = false;
1491
+ // The registry-owned notice prints once per session.
1492
+ let configIgnoredAnnounced = false;
1417
1493
  const devStartedAt = Date.now();
1418
1494
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
1419
1495
  const buildFile = join(appDir, ".monty", "build");
1420
1496
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
1421
- // The STUDIO schema channel: heartbeats carry the compiled schema, and
1422
- // edits to monty.config.ts are re-compiled (softly) so schema changes reach
1423
- // the platform within one heartbeat. Publish owns the LIVE schema.
1497
+ // The session schema channel (manifest-less apps): heartbeats carry the
1498
+ // compiled schema, and edits to monty.config.ts are re-compiled (softly)
1499
+ // so schema changes reach the platform within one heartbeat.
1500
+ // Registry-owned apps skip all of it — their schema lives behind the
1501
+ // doors, and the local config copy follows the registry (auto-pull below).
1424
1502
  let currentMeta = meta;
1503
+ // Once per registry change: the manifest hash we last tried to sync the
1504
+ // local config copy to (successful or refused — never loop on dirty).
1505
+ let syncAttemptedHash = null;
1425
1506
  const configPath = join(appDir, "monty.config.ts");
1426
- let configMtime = statSync(configPath).mtimeMs;
1507
+ let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
1427
1508
 
1428
1509
  // Advertise this session. The touch timer (not the platform heartbeat,
1429
1510
  // which starts minutes late or never when logged out) keeps updatedAt
@@ -1445,9 +1526,9 @@ async function dev() {
1445
1526
  loggedIn,
1446
1527
  appUrl: configOnly ? null : `http://localhost:${port}`,
1447
1528
  tunnelUrl: null,
1448
- studioUrl: loggedIn ? `${host}/studio/${meta.slug}` : null,
1529
+ // Field names are wire contract (the desktop reads them).
1449
1530
  previewUrl: loggedIn && !configOnly
1450
- ? `${host}/studio/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1531
+ ? `${host}/apps/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1451
1532
  : null,
1452
1533
  publishing: false,
1453
1534
  lastHeartbeatAt: null,
@@ -1456,7 +1537,7 @@ async function dev() {
1456
1537
  });
1457
1538
  touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
1458
1539
 
1459
- // The STUDIO cron runner: the Live counterpart is a real Cloudflare Cron
1540
+ // The session cron runner: the Live counterpart is a real Cloudflare Cron
1460
1541
  // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1461
1542
  // `schedule` entries against the UTC clock once per minute and invokes the
1462
1543
  // fn through the same /__monty/fn runtime (x-monty-schedule marks the
@@ -1492,6 +1573,7 @@ async function dev() {
1492
1573
  cronTimer = setInterval(cronTick, 20_000);
1493
1574
 
1494
1575
  async function refreshSchemaIfChanged() {
1576
+ if (registryOwned) return; // the doors own the schema; nothing to push
1495
1577
  try {
1496
1578
  const m = statSync(configPath).mtimeMs;
1497
1579
  if (m === configMtime) return;
@@ -1500,7 +1582,7 @@ async function dev() {
1500
1582
  if (fresh) {
1501
1583
  currentMeta = fresh;
1502
1584
  console.log(
1503
- `schema: monty.config.ts changed — Studio schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1585
+ `schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1504
1586
  );
1505
1587
  }
1506
1588
  } catch { /* transient fs hiccup — next beat retries */ }
@@ -1525,7 +1607,6 @@ async function dev() {
1525
1607
  if (hbTimer) clearInterval(hbTimer);
1526
1608
  if (touchTimer) clearInterval(touchTimer);
1527
1609
  if (cronTimer) clearInterval(cronTimer);
1528
- try { pubChild?.kill(); } catch { /* already gone */ }
1529
1610
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1530
1611
  // vite is a direct child (no npx wrapper), so this actually kills it —
1531
1612
  // a bare SIGTERM from the desktop must never orphan vite on the port.
@@ -1541,7 +1622,6 @@ async function dev() {
1541
1622
  if (hbTimer) clearInterval(hbTimer);
1542
1623
  if (touchTimer) clearInterval(touchTimer);
1543
1624
  if (cronTimer) clearInterval(cronTimer);
1544
- try { pubChild?.kill(); } catch { /* already gone */ }
1545
1625
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1546
1626
  try { child?.kill(); } catch { /* already gone */ }
1547
1627
  console.log(`dev-session: superseded — ${fix}`);
@@ -1577,14 +1657,18 @@ async function dev() {
1577
1657
  buildId,
1578
1658
  schemaJson: currentMeta.schemaJson,
1579
1659
  // App Manifest v2 (docs/manifest-v2.md) — present only for V2
1580
- // configs; the host forwards it to devManifestJson (Stage 3 wiring).
1660
+ // configs; lands only for manifest-less apps (the doors own the
1661
+ // rest).
1581
1662
  manifest: currentMeta.manifest,
1582
- // The CAS base for the Studio channel (see `monty schema pull`).
1663
+ // The CAS base for the manifest-less landing (see `monty schema
1664
+ // pull`).
1583
1665
  baseManifestHash:
1584
1666
  currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
1585
1667
  // The expose block rides the same compile as the schema — the dev
1586
- // visitor preview is gated on it (devExposureJson).
1668
+ // visitor preview of a manifest-less app is gated on it.
1587
1669
  exposure: currentMeta.exposure,
1670
+ // Rules ride the same channel (manifest-less apps only).
1671
+ rules: currentMeta.rules,
1588
1672
  }),
1589
1673
  signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
1590
1674
  });
@@ -1608,9 +1692,17 @@ async function dev() {
1608
1692
  }
1609
1693
  return false;
1610
1694
  }
1611
- if (currentMeta.manifest !== undefined) {
1612
- // This beat's manifest is now the stored Studio manifest the new
1613
- // CAS base for both channels' future pushes.
1695
+ if (data?.manifestDrift) {
1696
+ // The session registered, but the config push was HELD: the stored
1697
+ // schema changed since this checkout last synced (another editor).
1698
+ if (!driftAnnounced) {
1699
+ driftAnnounced = true;
1700
+ console.log(`schema drift (remote changes):${data.manifestDrift.summary ? `\n${data.manifestDrift.summary}` : ""}`);
1701
+ console.log(`config push held: ${data.manifestDrift.fix ?? "Run `monty schema pull`, merge, then save again."}`);
1702
+ }
1703
+ } else if (currentMeta.manifest !== undefined) {
1704
+ driftAnnounced = false;
1705
+ // This beat's manifest landed LIVE — the new CAS base.
1614
1706
  try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
1615
1707
  }
1616
1708
  if (!registeredOnce) {
@@ -1619,37 +1711,58 @@ async function dev() {
1619
1711
  } else {
1620
1712
  sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1621
1713
  }
1622
- if (data?.publishRequested && !publishing && !ended) {
1623
- publishing = true;
1624
- sf.write({ publishing: true });
1625
- console.log("publish: requested from the workspace building & uploading…");
1626
- const pubTee = logSink.source();
1627
- await new Promise((resolve) => {
1628
- pubChild = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
1629
- cwd: appDir,
1630
- stdio: ["ignore", "pipe", "pipe"],
1714
+ if (data?.configIgnored && !configIgnoredAnnounced) {
1715
+ configIgnoredAnnounced = true;
1716
+ console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set <file>`.");
1717
+ // Stamp registry ownership: from the next session on, the config
1718
+ // compile is skipped entirely.
1719
+ try {
1720
+ writeAppJson(appDir, {
1721
+ registryOwned: true,
1722
+ slug: meta.slug,
1723
+ ...(currentMeta.name ? { name: currentMeta.name } : {}),
1724
+ ...(currentMeta.icon ? { icon: currentMeta.icon } : {}),
1631
1725
  });
1632
- pubChild.stdout.on("data", (c) => {
1633
- process.stdout.write(c);
1634
- pubTee(c);
1635
- });
1636
- pubChild.stderr.on("data", (c) => {
1637
- process.stderr.write(c);
1638
- pubTee(c);
1639
- });
1640
- pubChild.on("exit", (code) => {
1641
- pubChild = null;
1642
- pubTee.flush();
1643
- console.log(
1644
- code === 0
1645
- ? "publish: done — the app is Live for the workspace (Studio session continues)"
1646
- : "publish: FAILED fix the errors above, then click Publish again",
1647
- );
1648
- resolve(undefined);
1649
- });
1650
- });
1651
- publishing = false;
1652
- sf.write({ publishing: false });
1726
+ } catch { /* the stamp is a convenience; the beat decided */ }
1727
+ }
1728
+ // The local config copy follows the registry: when the stored manifest
1729
+ // moved (another editor, the schema door) and the local file is clean,
1730
+ // regenerate it in place — the sync of the mechanical copy is
1731
+ // automatic. Dirty or uncompilable files are left alone with the fix.
1732
+ if (
1733
+ typeof data?.manifestHash === "string" &&
1734
+ existsSync(configPath) &&
1735
+ data.manifestHash !== readSchemaState(appDir)?.hash &&
1736
+ data.manifestHash !== syncAttemptedHash
1737
+ ) {
1738
+ syncAttemptedHash = data.manifestHash;
1739
+ try {
1740
+ // Already in sync (fresh checkout, no state file yet)? Stamp the
1741
+ // base and leave the file alone — regenerate only on real drift.
1742
+ let alreadySynced = false;
1743
+ try {
1744
+ const compiled = await compileAppConfig(appDir);
1745
+ if (compiled.manifest && manifestHash(compiled.manifest) === data.manifestHash) {
1746
+ writeSchemaState(appDir, data.manifestHash);
1747
+ alreadySynced = true;
1748
+ }
1749
+ } catch { /* uncompilable — let schemaPull's dirty check narrate */ }
1750
+ if (!alreadySynced) {
1751
+ await schemaPull({
1752
+ appDir,
1753
+ host,
1754
+ key: loadConfig()?.key ?? cfg.key,
1755
+ slug: meta.slug,
1756
+ force: false,
1757
+ compileAppConfig,
1758
+ fail: (code, fix) => {
1759
+ throw new Error(`${code} — ${fix}`);
1760
+ },
1761
+ });
1762
+ }
1763
+ } catch (e) {
1764
+ console.log(`schema: the workspace manifest changed but the local copy was NOT regenerated (${String(e?.message ?? e).slice(0, 240)})`);
1765
+ }
1653
1766
  }
1654
1767
  return true;
1655
1768
  } catch {
@@ -1660,7 +1773,7 @@ async function dev() {
1660
1773
 
1661
1774
  async function startDevSession() {
1662
1775
  if (!cfg?.key) {
1663
- console.log("dev: not logged in — workspace Studio disabled (run `monty login`)");
1776
+ console.log("dev: not logged in — workspace session disabled (run `monty login`)");
1664
1777
  return;
1665
1778
  }
1666
1779
  await clearDevSession();
@@ -1682,17 +1795,17 @@ async function dev() {
1682
1795
  if (ended || version !== tunnelVersion) return "superseded";
1683
1796
  if (!dnsLive) {
1684
1797
  if (initial) {
1685
- console.log("tunnel: DNS never propagated — Studio registered on localhost (visible on this machine's browser only)");
1798
+ console.log("tunnel: DNS never propagated — session registered on localhost (visible on this machine's browser only)");
1686
1799
  } else {
1687
- console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
1800
+ console.log("tunnel: DNS never propagated for the new URL — keeping the session offline until the next tunnel URL");
1688
1801
  }
1689
1802
  return "failed";
1690
1803
  }
1691
1804
  originUrl = url;
1692
1805
  sf.write({ tunnelUrl: url });
1693
- console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
1806
+ console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; session URL updated");
1694
1807
  if (!initial && !(await heartbeat(originUrl))) {
1695
- console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
1808
+ console.log("dev-session: the session still has no registered tunnel; the next heartbeat will retry");
1696
1809
  }
1697
1810
  return "activated";
1698
1811
  }
@@ -1716,34 +1829,40 @@ async function dev() {
1716
1829
  console.log(`tunnel: ${t.url}`);
1717
1830
  await activateTunnelUrl(t.url, { initial: true });
1718
1831
  } else {
1719
- console.log("tunnel: unavailable — Studio registered on localhost (visible on this machine's browser only)");
1832
+ console.log("tunnel: unavailable — session registered on localhost (visible in this machine's browser only)");
1720
1833
  }
1721
1834
  }
1722
1835
  const registered = await heartbeat(originUrl, { claim: true });
1723
1836
  console.log(
1724
1837
  registered
1725
- ? `studio: ${host}/studio/${meta.slug} — your app runs there while this is up; click Publish to go Live`
1726
- : `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
1838
+ ? `preview: ${host}/apps/${meta.slug} — the app surface follows this session while it runs; \`monty save\` updates the cloud copy`
1839
+ : `preview: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
1727
1840
  );
1728
1841
  hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
1729
1842
  }
1730
1843
 
1731
1844
  if (configOnly) {
1732
1845
  sf.write({ state: "ready" });
1733
- console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
1734
- console.log("ready: config-only — the Studio renders this app; edits push on save");
1846
+ console.log(
1847
+ registryOwned
1848
+ ? "ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`"
1849
+ : "ready: config-only — saves land LIVE; the workspace renders them within seconds",
1850
+ );
1735
1851
  void startDevSession();
1736
- // Config edits should reach the Studio in seconds, not a heartbeat:
1737
- // watch the mtime and trigger an early beat (which recompiles + pushes).
1738
- const cfgWatch = setInterval(() => {
1739
- try {
1740
- if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
1741
- } catch { /* transient fs hiccup */ }
1742
- }, 2000);
1743
- const stopWatch = () => clearInterval(cfgWatch);
1744
- process.on("SIGINT", stopWatch);
1745
- process.on("SIGTERM", stopWatch);
1746
- process.on("SIGHUP", stopWatch);
1852
+ if (!registryOwned) {
1853
+ // Manifest-less apps only: config edits should land in seconds, not a
1854
+ // heartbeat watch the mtime and trigger an early beat (which
1855
+ // recompiles + pushes). Registry-owned apps have nothing to push.
1856
+ const cfgWatch = setInterval(() => {
1857
+ try {
1858
+ if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
1859
+ } catch { /* transient fs hiccup */ }
1860
+ }, 2000);
1861
+ const stopWatch = () => clearInterval(cfgWatch);
1862
+ process.on("SIGINT", stopWatch);
1863
+ process.on("SIGTERM", stopWatch);
1864
+ process.on("SIGHUP", stopWatch);
1865
+ }
1747
1866
  }
1748
1867
 
1749
1868
  let announced = false;
@@ -1759,7 +1878,7 @@ async function dev() {
1759
1878
  if (!announced && /localhost:\d+/.test(text.replace(ANSI_RE, ""))) {
1760
1879
  announced = true;
1761
1880
  sf.write({ state: "ready" });
1762
- console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
1881
+ console.log(`data: live this session reads and writes the app\'s real records`);
1763
1882
  console.log(`ready: http://localhost:${port}`);
1764
1883
  void startDevSession();
1765
1884
  }
@@ -1949,7 +2068,7 @@ async function waitForDns(hostname) {
1949
2068
 
1950
2069
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
1951
2070
  // binary on first use). Resolves with the public URL, or null on failure —
1952
- // Studio then falls back to localhost-only registration. onOutput receives
2071
+ // the session then falls back to localhost-only registration. onOutput receives
1953
2072
  // every chunk (both fds) for the dev.log tee.
1954
2073
  function startTunnel(port, onUrlChange, onOutput) {
1955
2074
  return new Promise((resolve) => {
@@ -2027,10 +2146,55 @@ function resolveComponent(name) {
2027
2146
  // ── monty add page <name> ──────────────────────────────────────────────────
2028
2147
  // Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
2029
2148
  // use (config-only apps gain src/ + vite from the template — their
2030
- // monty.config.ts and AGENTS.md stay untouched), writes the page route, and
2031
- // registers `pages.<name> = { kind: "custom", path: "/<name>" }` so the
2032
- // platform shell mounts it. The Shopify model: system pages stay
2033
- // shell-rendered; only this page is the app's own code.
2149
+ // monty.config.ts and AGENTS.md stay untouched), declares
2150
+ // `pages.<name> = { kind: "custom", path: "/<name>" }`, and writes the page
2151
+ // route. DECLARE-FIRST: on a workspace-owned app the entry lands through
2152
+ // the schema door BEFORE the code exists — a save carrying an undeclared
2153
+ // route refuses (DEPLOY_UNDECLARED_PAGE). The Shopify model: system pages
2154
+ // stay shell-rendered; only this page is the app's own code.
2155
+
2156
+ // Declare the page through the schema door. Returns "declared" | "already"
2157
+ // (workspace-owned app) or "config" (manifest-less: the config file is
2158
+ // still that app's editor, the caller registers the entry there).
2159
+ async function declarePageThroughDoor(appDir, pageName) {
2160
+ const slug = readSlug(appDir);
2161
+ const { host, key } = loadConfig();
2162
+ if (!slug || !key) return "config";
2163
+ const read = await fetch(`${host}/api/schema?slug=${slug}`, {
2164
+ headers: { authorization: `Bearer ${key}` },
2165
+ }).catch(() => null);
2166
+ const readBody = await read?.json().catch(() => null);
2167
+ if (!read?.ok || !readBody?.ok || readBody.manifest === null) return "config";
2168
+ const manifest = readBody.manifest;
2169
+ const keyOf = (n) => n.toLowerCase().replace(/[^a-z0-9]/g, "");
2170
+ const declared = Object.keys(manifest.pages ?? {}).find((n) => keyOf(n) === keyOf(pageName));
2171
+ if (declared) {
2172
+ const kind = manifest.pages[declared]?.kind;
2173
+ if (kind !== "custom") {
2174
+ fail("PAGE_KIND", `"${declared}" is a ${kind} page in the app's manifest — custom code can't replace it. Pick another page name.`);
2175
+ }
2176
+ return "already";
2177
+ }
2178
+ // An empty pages block means default view pages derive from the tables;
2179
+ // materialize them first so declaring one custom page can't hide the rest.
2180
+ const pages =
2181
+ manifest.pages && Object.keys(manifest.pages).length > 0
2182
+ ? { ...manifest.pages }
2183
+ : Object.fromEntries(Object.keys(manifest.tables ?? {}).map((t) => [t, { kind: "view", table: t }]));
2184
+ pages[pageName] = { kind: "custom", path: `/${pageName}` };
2185
+ const res = await fetch(`${host}/api/schema`, {
2186
+ method: "POST",
2187
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
2188
+ body: JSON.stringify({ slug, manifest: { ...manifest, pages }, baseHash: readBody.hash }),
2189
+ });
2190
+ const body = await res.json().catch(() => null);
2191
+ if (!res.ok || !body?.ok) {
2192
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Declaring the page through the schema door failed — check the connection and retry.");
2193
+ }
2194
+ try { writeSchemaState(appDir, body.hash); } catch { /* state is advisory */ }
2195
+ return "declared";
2196
+ }
2197
+
2034
2198
  async function addPage(appDir, pageName) {
2035
2199
  if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
2036
2200
  fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
@@ -2048,6 +2212,15 @@ async function addPage(appDir, pageName) {
2048
2212
  if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
2049
2213
  console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
2050
2214
  }
2215
+ // Declare BEFORE any code exists: if the door refuses, nothing to clean up.
2216
+ const declared = await declarePageThroughDoor(appDir, pageName);
2217
+ if (declared !== "config") {
2218
+ console.log(
2219
+ declared === "declared"
2220
+ ? `declared: pages.${pageName} through the schema door — live in the workspace now`
2221
+ : `declared: pages.${pageName} already in the workspace manifest`,
2222
+ );
2223
+ }
2051
2224
 
2052
2225
  // First custom page on a config-only app: bring in the SPA scaffold.
2053
2226
  if (isConfigOnlyApp(appDir)) {
@@ -2083,6 +2256,9 @@ async function addPage(appDir, pageName) {
2083
2256
  pkg.scripts = { ...tplPkg.scripts, ...pkg.scripts };
2084
2257
  pkg.dependencies = { ...tplPkg.dependencies, ...pkg.dependencies };
2085
2258
  pkg.devDependencies = { ...tplPkg.devDependencies, ...pkg.devDependencies };
2259
+ // vite.config.ts imports the ESM-only sdk plugin — without the
2260
+ // template's module type the config loads as CJS and fails to resolve it.
2261
+ if (tplPkg.type && !pkg.type) pkg.type = tplPkg.type;
2086
2262
  // The published template pins the sdk; a workspace app may carry
2087
2263
  // workspace:* — the merge above keeps the app's existing pin either way.
2088
2264
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
@@ -2108,13 +2284,31 @@ async function addPage(appDir, pageName) {
2108
2284
  if (existsSync(starter)) rmSync(starter);
2109
2285
  appendFileSync(
2110
2286
  join(appDir, "AGENTS.md"),
2111
- `\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` entry in\nmonty.config.ts. System pages (views, dashboards) stay config-rendered —\nonly build bespoke UI here. \`monty dev\` serves both; \`monty deploy\`\npublishes both.\n`,
2287
+ `\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` declaration.\nDECLARE-FIRST: \`monty add page <name>\` declares the entry (through the\nschema door on workspace-owned apps) before writing the route — a save\ncarrying an undeclared route refuses. System pages (table views) stay\nshell-rendered only build bespoke UI here. \`monty dev\` serves both.\nAfter every meaningful change verified in dev, run\n\`monty save "<what changed>"\` — it pushes the work to the cloud copy,\nlike \`git push main\`.\n\nEvery custom page opens with \`PageHeader\` from \`@montytools/sdk/ui\` —\nthe same bar the shell renders on system pages (page actions go in it as\n\`PageHeaderButton\`s, \`primary\` for the one main action). Also there:\n\`FloatingBar\`/\`FloatingBarButton\` and the Lyra table classes\n\`SURFACE\`/\`THEAD\`/\`TH\`/\`ROW\`/\`CHIP\`.\n`,
2112
2288
  );
2113
2289
  }
2114
2290
 
2291
+ // The generated route imports @montytools/sdk/ui, whose Tailwind classes
2292
+ // only compile if the app's CSS scans the sdk dist — older scaffolds
2293
+ // predate the @source line.
2294
+ const cssPath = join(appDir, "src", "index.css");
2295
+ if (existsSync(cssPath)) {
2296
+ const css = readFileSync(cssPath, "utf8");
2297
+ if (!css.includes("@montytools/sdk/dist/ui.js")) {
2298
+ const lines = css.split("\n");
2299
+ let lastImport = -1;
2300
+ lines.forEach((l, i) => { if (/^@import\s/.test(l)) lastImport = i; });
2301
+ lines.splice(lastImport + 1, 0, '@source "../node_modules/@montytools/sdk/dist/ui.js";');
2302
+ writeFileSync(cssPath, lines.join("\n"));
2303
+ console.log("config: src/index.css now scans @montytools/sdk/ui (Tailwind @source)");
2304
+ }
2305
+ }
2306
+
2115
2307
  // The page route: a real, working start — SDK data hooks, shell-aware.
2116
2308
  mkdirSync(dirname(routeFile), { recursive: true });
2309
+ const pageTitle = (pageName[0].toUpperCase() + pageName.slice(1)).replace(/-/g, " ");
2117
2310
  writeFileSync(routeFile, `import { createFileRoute } from "@tanstack/react-router";
2311
+ import { PageHeader } from "@montytools/sdk/ui";
2118
2312
 
2119
2313
  export const Route = createFileRoute("/${pageName}")({
2120
2314
  component: ${pageComponentName(pageName)},
@@ -2122,41 +2316,49 @@ export const Route = createFileRoute("/${pageName}")({
2122
2316
 
2123
2317
  // A CUSTOM page: bespoke UI mounted inside the platform shell at
2124
2318
  // /apps/<slug>/${pageName}. Data comes from @montytools/sdk hooks
2125
- // (useList/useInsert/…) against the same tables the shell renders.
2319
+ // (useList/useInsert/…) against the same tables the shell renders. The
2320
+ // PageHeader bar is the same chrome system pages wear — keep it first, put
2321
+ // page actions in it (PageHeaderButton).
2126
2322
  function ${pageComponentName(pageName)}() {
2127
2323
  return (
2128
- <main className="p-6">
2129
- <h1 className="text-lg font-semibold">${appName} — ${pageName}</h1>
2130
- <p className="mt-2 text-sm text-muted-foreground">
2131
- Build this page. It ships with the app on the next \`monty deploy\`.
2132
- </p>
2133
- </main>
2324
+ <div className="flex h-full min-h-dvh flex-col">
2325
+ <PageHeader title="${pageTitle}" />
2326
+ <main className="min-h-0 flex-1 overflow-auto p-6">
2327
+ <p className="text-sm text-muted-foreground">
2328
+ Build this page. It ships with the app on the next \`monty save\`.
2329
+ </p>
2330
+ </main>
2331
+ </div>
2134
2332
  );
2135
2333
  }
2136
2334
  `);
2137
2335
  console.log(`page: src/routes/${pageName}.tsx`);
2138
2336
 
2139
- // Register the page in the config's pages block (insert or create).
2140
- const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
2141
- let next = null;
2142
- if (/^(\s*)pages:\s*{/m.test(config)) {
2143
- next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
2144
- } else {
2145
- // No pages block: add one right before the config's closing `});`.
2146
- const close = config.lastIndexOf("});");
2147
- if (close !== -1) {
2148
- next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
2337
+ // Manifest-less apps only: the config file is still their editor, so the
2338
+ // entry registers there (workspace-owned apps declared through the door
2339
+ // above a config edit would be inert).
2340
+ if (declared === "config") {
2341
+ const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
2342
+ let next = null;
2343
+ if (/^(\s*)pages:\s*{/m.test(config)) {
2344
+ next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
2345
+ } else {
2346
+ // No pages block: add one right before the config's closing `});`.
2347
+ const close = config.lastIndexOf("});");
2348
+ if (close !== -1) {
2349
+ next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
2350
+ }
2351
+ }
2352
+ if (next) {
2353
+ writeFileSync(configPath, next);
2354
+ console.log(`config: pages.${pageName} registered in monty.config.ts`);
2355
+ } else {
2356
+ console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
2149
2357
  }
2150
- }
2151
- if (next) {
2152
- writeFileSync(configPath, next);
2153
- console.log(`config: pages.${pageName} registered in monty.config.ts`);
2154
- } else {
2155
- console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
2156
2358
  }
2157
2359
 
2158
2360
  console.log(`added: custom page "${pageName}"`);
2159
- console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty deploy\` publishes it.`);
2361
+ console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
2160
2362
  }
2161
2363
 
2162
2364
  function pageComponentName(pageName) {
@@ -2246,7 +2448,12 @@ async function docs() {
2246
2448
  // readable back. Read from the arg, then a TTY prompt, then stdin (piping).
2247
2449
  async function secret() {
2248
2450
  const appDir = requireAppDir("secret");
2249
- const meta = await compileConfig(appDir);
2451
+ // Identity comes from the stamp when it exists — no config compile for
2452
+ // one slug read.
2453
+ const stamped = readAppJson(appDir);
2454
+ const meta = typeof stamped?.slug === "string" && stamped.slug
2455
+ ? { slug: stamped.slug }
2456
+ : await compileConfig(appDir);
2250
2457
  const config = loadConfig();
2251
2458
  if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
2252
2459
  const [sub, name] = rest.filter((a) => !a.startsWith("-"));
@@ -2283,13 +2490,25 @@ async function secret() {
2283
2490
  console.log(del ? `secret: removed ${name} from ${meta.slug}` : `secret: set ${name} on ${meta.slug} (write-only; not readable back)`);
2284
2491
  }
2285
2492
 
2286
- // ── monty deploy ───────────────────────────────────────────────────────────
2493
+ // ── monty save ─────────────────────────────────────────────────────────────
2494
+ // Push the working copy to the cloud copy, like `git push main`. `deploy` is
2495
+ // the compat alias; both run the same pipeline (build + typecheck gate every
2496
+ // save, then one multipart POST). The optional message rides the deploy meta
2497
+ // so the platform can narrate the save later.
2287
2498
  async function deploy() {
2288
- const appDir = requireAppDir("deploy");
2499
+ const appDir = requireAppDir(command);
2289
2500
  ensureSdk(appDir);
2290
- if (!rest.includes("--from-dev")) {
2291
- console.log("note: direct deploy ships straight to Live, skipping workspace review — the usual flow is `monty dev` (Studio) + the Publish button in the workspace.");
2292
- }
2501
+ const message = rest.find((a) => !a.startsWith("-")) ?? null;
2502
+ const saveJson = (state) => writeSaveJson(appDir, { message, ...state });
2503
+ saveJson({ status: "saving" });
2504
+ saveFailNote = (code, fix) => saveJson({ status: "error", detail: fix });
2505
+ process.on("exit", (exitCode) => {
2506
+ // A crash that never reached fail() (network throw, unhandled rejection)
2507
+ // must not leave the chip on "Saving…" forever.
2508
+ if (exitCode !== 0 && saveFailNote) {
2509
+ saveJson({ status: "error", detail: "The save stopped before finishing. Run `monty save` again." });
2510
+ }
2511
+ });
2293
2512
  const config = loadConfig();
2294
2513
  if (!config?.key) {
2295
2514
  fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
@@ -2300,6 +2519,21 @@ async function deploy() {
2300
2519
  console.log("compile: monty.config.ts");
2301
2520
  const meta = await compileConfig(appDir);
2302
2521
  console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
2522
+ // `monty save "what changed"` — the message rides the meta for the
2523
+ // platform to render as this save's Activity row.
2524
+ if (message) meta.message = message;
2525
+
2526
+ // Registry-owned apps ship IMPLEMENTATION ONLY: manifest, schema, rules,
2527
+ // and exposure are door-owned (the server holds them regardless — not
2528
+ // sending them keeps the wire honest). Code-door declarations
2529
+ // (publicFns, schedule, pages, functions) still ride.
2530
+ const registryOwned = readAppJson(appDir)?.registryOwned === true;
2531
+ if (registryOwned) {
2532
+ delete meta.schemaJson;
2533
+ delete meta.manifest;
2534
+ delete meta.exposure;
2535
+ delete meta.rules;
2536
+ }
2303
2537
 
2304
2538
  // CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
2305
2539
  // the platform shell renders the app.
@@ -2310,8 +2544,8 @@ async function deploy() {
2310
2544
  run(appDir, "build", ["npx", "vite", "build"],
2311
2545
  "The production build failed. Read the vite error above; it names the file to fix.");
2312
2546
  run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
2313
- "TypeScript errors above. Fix them in the listed files; `monty deploy` never uploads code that does not compile.");
2314
- } else if (!meta.manifest) {
2547
+ "TypeScript errors above. Fix them in the listed files; `monty save` never uploads code that does not compile.");
2548
+ } else if (!registryOwned && !meta.manifest) {
2315
2549
  fail("MANIFEST_MISSING",
2316
2550
  "This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
2317
2551
  }
@@ -2347,9 +2581,15 @@ async function deploy() {
2347
2581
  `schedule targets "${name}" but server/index.ts exports no such function. Export it (e.g. \`export async function ${name}(args, ctx) {…}\`) or remove the entry from monty.config.ts.`);
2348
2582
  }
2349
2583
  }
2350
- meta.fns = serverBundle.fns;
2584
+ // Wire compat: meta.fns stays the UNION (older hosts gate dispatch on
2585
+ // it); meta.datasets is the additive split newer hosts classify with.
2586
+ meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
2587
+ if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
2351
2588
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
2352
- console.log(`fns: bundled ${serverBundle.fns.length} server function(s) (${serverBundle.fns.join(", ")})`);
2589
+ const bundled = [];
2590
+ if (serverBundle.fns.length > 0) bundled.push(`${serverBundle.fns.length} function(s) (${serverBundle.fns.join(", ")})`);
2591
+ if (serverBundle.datasets.length > 0) bundled.push(`${serverBundle.datasets.length} dataset(s) (${serverBundle.datasets.join(", ")})`);
2592
+ console.log(`fns: bundled ${bundled.join(" + ")}`);
2353
2593
  if (publicFns.length > 0) {
2354
2594
  console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
2355
2595
  }
@@ -2357,6 +2597,16 @@ async function deploy() {
2357
2597
  console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
2358
2598
  }
2359
2599
  }
2600
+ // 3a½) Custom pages, by route-file convention (top-level src/routes/
2601
+ // files) — registered as fnsJson.pages so the shell's nav knows what this
2602
+ // bundle ships without a manifest entry.
2603
+ if (!configOnly) {
2604
+ const pages = discoverPages(appDir);
2605
+ if (pages.length > 0) {
2606
+ meta.pages = pages;
2607
+ console.log(`pages: ${pages.map((p) => p.name).join(", ")}`);
2608
+ }
2609
+ }
2360
2610
  // 3b) SOURCE snapshot rides every publish. Without it the platform keeps
2361
2611
  // only the minified bundle and the sole copy of the app's code is this
2362
2612
  // folder — delete it and the source is gone forever. The snapshot is what
@@ -2366,14 +2616,14 @@ async function deploy() {
2366
2616
  {
2367
2617
  const packed = packSource(appDir);
2368
2618
  if (packed === null) {
2369
- console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this publish.");
2619
+ console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this save.");
2370
2620
  } else if (packed.tooLarge) {
2371
2621
  console.log("source: WARNING — snapshot exceeds 10 MB, skipped; `monty pull` will not work for this app. Remove large assets from the app folder.");
2372
2622
  } else {
2373
2623
  sourceHash = packed.hash;
2374
2624
  meta.sourceHash = sourceHash;
2375
2625
  form.set("source", new Blob([packed.buf]), "source.tar.gz");
2376
- console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this publish (restore anywhere: monty pull ${meta.slug})`);
2626
+ console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
2377
2627
  }
2378
2628
  }
2379
2629
  // V2 schema CAS: prove which stored manifest this checkout last synced,
@@ -2409,9 +2659,14 @@ async function deploy() {
2409
2659
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
2410
2660
  }
2411
2661
  console.log(`origin: ${body.origin}`);
2412
- console.log(`deployed: ${body.url} (version ${body.version})`);
2413
- // The manifest just published IS the new CAS base.
2414
- if (meta.manifest !== undefined) {
2662
+ console.log(`saved: ${body.url} (version ${body.version})`);
2663
+ if (body.manifestDrift) {
2664
+ // The code shipped; the config was HELD — the stored schema changed
2665
+ // since this checkout last synced (another editor).
2666
+ console.log(`schema drift (remote changes):${body.manifestDrift.summary ? `\n${body.manifestDrift.summary}` : ""}`);
2667
+ console.log(`config push held: ${body.manifestDrift.fix ?? "Run `monty schema pull`, merge, then deploy again."}`);
2668
+ } else if (meta.manifest !== undefined) {
2669
+ // The manifest just published IS the new CAS base.
2415
2670
  writeSchemaState(appDir, manifestHash(meta.manifest));
2416
2671
  }
2417
2672
  // Stamp what was published — pull uses this to tell "unchanged since last
@@ -2422,15 +2677,22 @@ async function deploy() {
2422
2677
  JSON.stringify({ hash: sourceHash, syncedAt: Date.now() }) + "\n",
2423
2678
  );
2424
2679
  }
2680
+ saveFailNote = null;
2681
+ saveJson({ status: "saved" });
2425
2682
  }
2426
2683
 
2427
- // Bundle server/index.ts (if present) into ONE Worker script: a generated
2428
- // entry wraps the app's exports with @montytools/sdk/fn-worker's makeFnWorker,
2429
- // esbuild bundles it for workerd. node: imports are rejected at compile time
2430
- // Live runs on Cloudflare Workers, not Node. Returns { code, fns } or null.
2684
+ // Bundle the app's server code (if present) into ONE Worker script: server/
2685
+ // index.ts (functions custom code) and datasets/index.ts (defineDataset
2686
+ // table feeds) merge into a generated entry wrapped with @montytools/sdk/
2687
+ // fn-worker's makeFnWorker; esbuild bundles it for workerd. node: imports are
2688
+ // rejected at compile time — Live runs on Cloudflare Workers, not Node.
2689
+ // Returns { code, fns, datasets } (per-folder export names) or null.
2431
2690
  async function bundleServerFns(appDir, schedule) {
2432
2691
  const serverEntry = join(appDir, "server", "index.ts");
2433
- if (!existsSync(serverEntry)) return null;
2692
+ const datasetsEntry = join(appDir, "datasets", "index.ts");
2693
+ const hasServer = existsSync(serverEntry);
2694
+ const hasDatasets = existsSync(datasetsEntry);
2695
+ if (!hasServer && !hasDatasets) return null;
2434
2696
  const { build } = await import("esbuild");
2435
2697
  const tmpDir = join(appDir, ".monty");
2436
2698
  mkdirSync(tmpDir, { recursive: true });
@@ -2440,9 +2702,10 @@ async function bundleServerFns(appDir, schedule) {
2440
2702
  // hands back only the matching cron expression, so the worker needs the
2441
2703
  // expression→fn mapping at runtime.
2442
2704
  writeFileSync(entry, [
2443
- `import * as appFns from "../server/index";`,
2705
+ hasServer ? `import * as appFns from "../server/index";` : `const appFns = {};`,
2706
+ hasDatasets ? `import * as appDatasets from "../datasets/index";` : `const appDatasets = {};`,
2444
2707
  `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
2445
- `export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
2708
+ `export default makeFnWorker({ ...appFns, ...appDatasets }, { schedule: ${JSON.stringify(schedule ?? {})} });`,
2446
2709
  ].join("\n"));
2447
2710
  // Fail the deploy if server code reaches for Node built-ins — a Worker
2448
2711
  // can't run them, and a silent runtime crash on Live is the worst outcome.
@@ -2471,15 +2734,20 @@ async function bundleServerFns(appDir, schedule) {
2471
2734
  logLevel: "silent",
2472
2735
  plugins: [banPlatformImports],
2473
2736
  });
2474
- fns = discoverFnExports(serverEntry);
2475
- if (fns.length === 0) {
2476
- fail("NO_FN_EXPORTS", "server/index.ts exists but exports no async functions. Export named functions like `export async function score(args, ctx) {…}`, or remove the folder.");
2737
+ fns = hasServer ? discoverFnExports(serverEntry) : [];
2738
+ const datasets = hasDatasets ? discoverFnExports(datasetsEntry) : [];
2739
+ const dup = fns.filter((f) => datasets.includes(f));
2740
+ if (dup.length > 0) {
2741
+ fail("DUPLICATE_EXPORT", `"${dup[0]}" is exported from both server/index.ts and datasets/index.ts — one name, one home. Remove one of the two exports.`);
2477
2742
  }
2478
- return { code: readFileSync(out, "utf8"), fns };
2743
+ if (fns.length === 0 && datasets.length === 0) {
2744
+ fail("NO_FN_EXPORTS", "server/index.ts / datasets/index.ts exist but export no functions. Export named functions like `export async function score(args, ctx) {…}`, or remove the folder.");
2745
+ }
2746
+ return { code: readFileSync(out, "utf8"), fns, datasets };
2479
2747
  } catch (e) {
2480
- if (e?.code === "NO_FN_EXPORTS") throw e; // fail() already exited; guard for safety
2748
+ if (e?.code === "NO_FN_EXPORTS" || e?.code === "DUPLICATE_EXPORT") throw e; // fail() already exited; guard for safety
2481
2749
  const msg = e?.errors?.[0]?.text ?? e?.message ?? String(e);
2482
- fail("FN_BUNDLE_FAILED", `Could not bundle server/index.ts: ${msg}`);
2750
+ fail("FN_BUNDLE_FAILED", `Could not bundle the app's server code: ${msg}`);
2483
2751
  } finally {
2484
2752
  rmSync(entry, { force: true });
2485
2753
  rmSync(out, { force: true });
@@ -2503,7 +2771,7 @@ function discoverFnExports(serverEntry) {
2503
2771
  }
2504
2772
 
2505
2773
  // Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
2506
- // Studio heartbeat's last good schema through transient config breakage.
2774
+ // the session heartbeat's last good schema through transient config breakage.
2507
2775
  async function compileConfig(appDir, { soft = false } = {}) {
2508
2776
  try {
2509
2777
  // Config-only apps (no SPA) always compile a manifest: the platform
@@ -2542,7 +2810,7 @@ function walk(dir) {
2542
2810
  return out;
2543
2811
  }
2544
2812
 
2545
- // ── cron matching (the Studio ticker in `monty dev`) ──────────────────────
2813
+ // ── cron matching (the session's cron ticker in `monty dev`) ─────────────
2546
2814
  // UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
2547
2815
  // names (JAN, MON). Deliberately forgiving: an unparsable field simply never
2548
2816
  // matches locally — Cloudflare is the syntax authority at deploy, so a bad
@@ -2603,15 +2871,14 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
2603
2871
  // terminal — no browser, no dev session. Auth is the mk_ key exchanged at
2604
2872
  // /api/dev-token for a 5-minute workspace token (member lane, org_id from
2605
2873
  // the verified JWT), then the 6 public records functions over Convex's HTTP
2606
- // API. `app` picks the records namespace: the plain slug is Live; --studio
2607
- // targets "{slug}#dev" (the Studio sandbox same wire literal the dev
2608
- // shell uses). Results are ONE JSON document on stdout so agents can pipe.
2874
+ // API. `app` is the plain slug every app has ONE set of records.
2875
+ // Results are ONE JSON document on stdout so agents can pipe.
2609
2876
 
2610
- const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
2877
+ const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host", "name", "type", "out", "table", "record", "field"]);
2611
2878
 
2612
2879
  // rest, minus flags AND their values — `monty data list leads --app crm`
2613
- // must not read "crm" as a positional. Boolean flags (--studio) have no
2614
- // value and are skipped alone.
2880
+ // must not read "crm" as a positional. Boolean flags have no value and
2881
+ // are skipped alone.
2615
2882
  function dataPositionals() {
2616
2883
  const out = [];
2617
2884
  for (let i = 0; i < rest.length; i++) {
@@ -2645,7 +2912,10 @@ function resolveDataApp() {
2645
2912
  if (!slug) {
2646
2913
  fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
2647
2914
  }
2648
- return rest.includes("--studio") ? `${slug}#dev` : slug;
2915
+ if (rest.includes("--studio")) {
2916
+ fail("STUDIO_REMOVED", "The session sandbox is gone — every app has one set of records, and data verbs always target it. Drop --studio.");
2917
+ }
2918
+ return slug;
2649
2919
  }
2650
2920
 
2651
2921
  // mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
@@ -2666,7 +2936,7 @@ async function dataAuth() {
2666
2936
  if (!r.ok || !body?.token) {
2667
2937
  fail(body?.code ?? `HTTP_${r.status}`, body?.fix ?? "Minting a workspace token failed. Run `monty login`, then retry.");
2668
2938
  }
2669
- return { convexUrl, token: body.token };
2939
+ return { host, convexUrl, token: body.token };
2670
2940
  }
2671
2941
 
2672
2942
  // One records function over Convex's public HTTP API (plain-JSON format —
@@ -2712,7 +2982,7 @@ function flattenRow(doc) {
2712
2982
  }
2713
2983
 
2714
2984
  function dataUsage() {
2715
- console.log("usage: monty data <verb> [table] [flags] read/write an app's Live records (add --studio for the Studio sandbox)");
2985
+ console.log("usage: monty data <verb> [table] [flags] read/write an app's records");
2716
2986
  console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
2717
2987
  console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
2718
2988
  console.log(" get <table> <id>");
@@ -2720,10 +2990,57 @@ function dataUsage() {
2720
2990
  console.log(" update <table> <id> --data '<json>' [--unset field,field]");
2721
2991
  console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
2722
2992
  console.log(" remove <table> <id>");
2723
- console.log("target: --app <slug> (or run inside the app folder); default is LIVE data --studio targets the sandbox");
2993
+ console.log(" upload <path> [--name N] [--type mime] store a file (10MB cap); prints the descriptor to put in a `file` field");
2994
+ console.log(" [--table <t> --record <id> --field <f>] …and set that row's field in the same command");
2995
+ console.log(" download <file-id> [--out path] fetch a stored file's bytes (id from a row's file field)");
2996
+ console.log("target: --app <slug> (or run inside the app folder)");
2724
2997
  process.exit(1);
2725
2998
  }
2726
2999
 
3000
+ // Uploads without --type get the MIME their extension implies; unknown
3001
+ // extensions stay application/octet-stream (the server stores, never sniffs).
3002
+ const MIME_BY_EXT = {
3003
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
3004
+ webp: "image/webp", svg: "image/svg+xml", ico: "image/x-icon",
3005
+ pdf: "application/pdf", json: "application/json", csv: "text/csv",
3006
+ txt: "text/plain", md: "text/markdown", html: "text/html",
3007
+ zip: "application/zip", mp3: "audio/mpeg", wav: "audio/wav",
3008
+ mp4: "video/mp4", webm: "video/webm",
3009
+ };
3010
+
3011
+ function inferContentType(name) {
3012
+ const ext = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
3013
+ return (ext && MIME_BY_EXT[ext]) || "application/octet-stream";
3014
+ }
3015
+
3016
+ const DATA_MAX_FILE_BYTES = 10 * 1024 * 1024;
3017
+
3018
+ // POST/GET on the host's /api/files — the same rail the SDK file helpers
3019
+ // ride, authorized with the invocation's 5-minute workspace token.
3020
+ async function callFiles(method, params, auth, app, opts = {}) {
3021
+ const url = new URL("/api/files", auth.host);
3022
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
3023
+ let r;
3024
+ try {
3025
+ r = await fetch(url, {
3026
+ method,
3027
+ headers: {
3028
+ authorization: `Bearer ${auth.token}`,
3029
+ "x-monty-app": app,
3030
+ ...(opts.contentType ? { "content-type": opts.contentType } : {}),
3031
+ },
3032
+ ...(opts.body !== undefined ? { body: opts.body } : {}),
3033
+ });
3034
+ } catch {
3035
+ fail("HOST_UNREACHABLE", `The file endpoint at ${auth.host} did not answer — check the network and retry.`);
3036
+ }
3037
+ if (!r.ok) {
3038
+ const err = await r.json().catch(() => null);
3039
+ fail(err?.code ?? `HTTP_${r.status}`, err?.fix ?? "The file request failed. Retry; if it persists, report it.");
3040
+ }
3041
+ return r;
3042
+ }
3043
+
2727
3044
  async function data() {
2728
3045
  const [verb, table, id] = dataPositionals();
2729
3046
 
@@ -2758,6 +3075,82 @@ async function data() {
2758
3075
  return;
2759
3076
  }
2760
3077
 
3078
+ if (verb === "upload") {
3079
+ // The positional is a file PATH, not a table — the descriptor this
3080
+ // prints is what a `file`-typed field stores; --table/--record/--field
3081
+ // set it on an existing row in the same command.
3082
+ const path = table;
3083
+ if (!path) fail("MISSING_PATH", "Usage: monty data upload <path> [--name N] [--type mime] [--table <t> --record <id> --field <f>].");
3084
+ const attachTable = flag("table");
3085
+ const attachRecord = flag("record");
3086
+ const attachField = flag("field");
3087
+ const attachFlags = [attachTable, attachRecord, attachField].filter((f) => f !== undefined);
3088
+ if (attachFlags.length > 0 && attachFlags.length < 3) {
3089
+ fail("BAD_ATTACH", "Attaching needs all three of --table, --record, and --field — or none (then put the printed descriptor in a file field yourself).");
3090
+ }
3091
+ let bytes;
3092
+ try {
3093
+ bytes = readFileSync(path);
3094
+ } catch {
3095
+ fail("NO_SUCH_FILE", `Could not read "${path}" — check the path.`);
3096
+ }
3097
+ if (bytes.byteLength === 0) fail("EMPTY_FILE", `"${path}" is empty — nothing to upload.`);
3098
+ if (bytes.byteLength > DATA_MAX_FILE_BYTES) {
3099
+ fail("FILE_TOO_LARGE", `Files are limited to ${DATA_MAX_FILE_BYTES} bytes; "${path}" is ${bytes.byteLength}. Compress it first.`);
3100
+ }
3101
+ const name = flag("name") ?? basename(path);
3102
+ const contentType = flag("type") ?? inferContentType(name);
3103
+ const app = resolveDataApp();
3104
+ const auth = await dataAuth();
3105
+ if (attachFlags.length === 3) {
3106
+ // Prove the target row exists BEFORE storing bytes — a failed attach
3107
+ // after the upload would orphan the file.
3108
+ const row = await callRecords("query", "get", { app, table: attachTable, id: attachRecord }, auth);
3109
+ if (!row) fail("NOT_FOUND", `No record "${attachRecord}" in table "${attachTable}" — ids come from \`monty data list ${attachTable}\`.`);
3110
+ }
3111
+ const res = await callFiles("POST", { name }, auth, app, { body: bytes, contentType });
3112
+ const file = await res.json();
3113
+ if (attachFlags.length === 3) {
3114
+ await callRecords("mutation", "update", {
3115
+ app,
3116
+ table: attachTable,
3117
+ id: attachRecord,
3118
+ data: { [attachField]: file },
3119
+ }, auth);
3120
+ printJson({ ok: true, id: attachRecord, field: attachField, file });
3121
+ } else {
3122
+ printJson({ file });
3123
+ }
3124
+ return;
3125
+ }
3126
+
3127
+ if (verb === "download") {
3128
+ // The positional is the file id (a row's file-field descriptor carries
3129
+ // it), not a table.
3130
+ const fileId = table;
3131
+ if (!fileId) fail("MISSING_ID", "Usage: monty data download <file-id> [--out path] — ids come from a row's file field (its `id` key).");
3132
+ const app = resolveDataApp();
3133
+ const auth = await dataAuth();
3134
+ const res = await callFiles("GET", { id: fileId }, auth, app);
3135
+ const disposition = res.headers.get("content-disposition") ?? "";
3136
+ const remoteName = /filename="([^"]+)"/.exec(disposition)?.[1] ?? fileId;
3137
+ const out = flag("out") ?? remoteName;
3138
+ const body = Buffer.from(await res.arrayBuffer());
3139
+ try {
3140
+ writeFileSync(out, body);
3141
+ } catch {
3142
+ fail("WRITE_FAILED", `Could not write to "${out}" — check the path and permissions.`);
3143
+ }
3144
+ printJson({
3145
+ ok: true,
3146
+ path: out,
3147
+ name: remoteName,
3148
+ contentType: res.headers.get("content-type") ?? "application/octet-stream",
3149
+ size: body.byteLength,
3150
+ });
3151
+ return;
3152
+ }
3153
+
2761
3154
  const VERBS = new Set(["list", "get", "insert", "update", "upsert", "remove"]);
2762
3155
  if (!verb || !VERBS.has(verb)) dataUsage();
2763
3156
  if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
@@ -2866,34 +3259,99 @@ if (command !== "dev" && command !== "logs") {
2866
3259
  installSkills({ appDir: findAppRoot(process.cwd()) });
2867
3260
  }
2868
3261
 
2869
- // ── monty schema — the schema-as-data door of the CLI ─────────────────────
2870
- // `monty schema pull [slug] [--force]`: regenerate monty.config.ts from the
2871
- // app's stored Live manifest (after another agent edited it via the API).
2872
- // Distinct from `monty pull`, which restores the whole source snapshot.
3262
+ // ── monty schema — the manifest door ──────────────────────────────────────
3263
+ // The app's data half (tables, field algebra, metrics, settings, pages)
3264
+ // lives ONLY in the workspace. `monty schema [slug]` prints the stored
3265
+ // manifest as JSON (and stamps the CAS base); edit that JSON and
3266
+ // `monty schema set <file|->` writes it back through the one landing —
3267
+ // validated server-side, additive-only by default, CAS against what you
3268
+ // read. `monty schema pull` (legacy) regenerates monty.config.ts.
2873
3269
  async function schemaCmd() {
2874
3270
  const verb = rest[0];
2875
- if (verb !== "pull") {
2876
- console.log("usage: monty schema pull [slug] [--force]");
2877
- console.log(" pull regenerate monty.config.ts from the app's stored manifest (.bak kept; --force discards local schema edits)");
2878
- process.exit(verb ? 1 : 0);
2879
- }
2880
3271
  const { host, key } = loadConfig() ?? {};
2881
3272
  if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
2882
- const appDir = findAppRoot(process.cwd()) ?? process.cwd();
2883
- let slug = rest.slice(1).find((a) => !a.startsWith("--"));
2884
- if (!slug) {
3273
+ const appDir = findAppRoot(process.cwd());
3274
+
3275
+ if (verb === "pull") {
3276
+ const dir = appDir ?? process.cwd();
3277
+ let slug = rest.slice(1).find((a) => !a.startsWith("--"));
3278
+ if (!slug) {
3279
+ try {
3280
+ slug = (await compileAppConfig(dir)).slug;
3281
+ } catch {
3282
+ fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
3283
+ }
3284
+ }
3285
+ await schemaPull({
3286
+ appDir: dir, host, key, slug,
3287
+ force: rest.includes("--force"),
3288
+ compileAppConfig,
3289
+ fail,
3290
+ });
3291
+ return;
3292
+ }
3293
+
3294
+ if (verb === "set") {
3295
+ const target = rest[1];
3296
+ if (!target) {
3297
+ fail("SCHEMA_USAGE", "Usage: monty schema set <file.json|-> [--allow-breaking] — the JSON is a full manifest (start from `monty schema`).");
3298
+ }
3299
+ let raw;
2885
3300
  try {
2886
- slug = (await compileAppConfig(appDir)).slug;
3301
+ raw = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
2887
3302
  } catch {
2888
- fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) no compilable monty.config.ts here to read it from.");
3303
+ fail("SCHEMA_USAGE", `Could not read ${target === "-" ? "stdin" : target}. Pass a manifest JSON file, or - for stdin.`);
2889
3304
  }
3305
+ let manifest;
3306
+ try {
3307
+ manifest = JSON.parse(raw);
3308
+ } catch {
3309
+ fail("BAD_MANIFEST_JSON", "That is not valid JSON. Start from `monty schema` output, edit, and set the whole document back.");
3310
+ }
3311
+ const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
3312
+ if (!slug) fail("INVALID_SLUG", "The manifest carries no slug and this is not an app folder — set `slug` in the JSON.");
3313
+ // CAS: prove which stored manifest this edit was based on (stamped by
3314
+ // the last `monty schema` read in this folder). Absent = trusting push.
3315
+ const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
3316
+ const res = await fetch(`${host}/api/schema`, {
3317
+ method: "POST",
3318
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
3319
+ body: JSON.stringify({
3320
+ slug,
3321
+ manifest,
3322
+ ...(base ? { baseHash: base } : {}),
3323
+ ...(rest.includes("--allow-breaking") ? { allowBreaking: true } : {}),
3324
+ }),
3325
+ });
3326
+ const body = await res.json().catch(() => null);
3327
+ if (!res.ok || !body?.ok) {
3328
+ if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
3329
+ console.log(`schema drift (remote changes):\n${body.summary}`);
3330
+ }
3331
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the manifest failed — is the Monty host reachable?");
3332
+ }
3333
+ if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3334
+ console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
3335
+ return;
2890
3336
  }
2891
- await schemaPull({
2892
- appDir, host, key, slug,
2893
- force: rest.includes("--force"),
2894
- compileAppConfig,
2895
- fail,
3337
+
3338
+ // Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
3339
+ // JSON (pipe it to a file, edit, `monty schema set` it back).
3340
+ const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
3341
+ if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
3342
+ const res = await fetch(`${host}/api/schema?slug=${slug}`, {
3343
+ headers: { authorization: `Bearer ${key}` },
2896
3344
  });
3345
+ const body = await res.json().catch(() => null);
3346
+ if (!res.ok || !body?.ok) {
3347
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the manifest — check the connection and `monty login`.");
3348
+ }
3349
+ if (body.manifest === null) {
3350
+ 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>.`);
3351
+ }
3352
+ if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3353
+ console.error(`# ${slug} — manifest hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3354
+ console.log(JSON.stringify(body.manifest, null, 2));
2897
3355
  }
2898
3356
 
2899
3357
  switch (command) {
@@ -2907,7 +3365,7 @@ switch (command) {
2907
3365
  await pull();
2908
3366
  break;
2909
3367
  case "commit":
2910
- await commit();
3368
+ fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
2911
3369
  break;
2912
3370
  case "log":
2913
3371
  case "versions":
@@ -2939,7 +3397,7 @@ switch (command) {
2939
3397
  apps();
2940
3398
  break;
2941
3399
  case "skills":
2942
- installSkills({ appDir: findAppRoot(process.cwd()), silent: false });
3400
+ installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
2943
3401
  console.log("skills: up to date");
2944
3402
  break;
2945
3403
  case "install":
@@ -2951,6 +3409,7 @@ switch (command) {
2951
3409
  case "typecheck":
2952
3410
  typecheckApp();
2953
3411
  break;
3412
+ case "save":
2954
3413
  case "deploy":
2955
3414
  await deploy();
2956
3415
  break;
@@ -2964,14 +3423,13 @@ switch (command) {
2964
3423
  await secret();
2965
3424
  break;
2966
3425
  default:
2967
- console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|data|skills>");
3426
+ console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|skills>");
2968
3427
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
2969
3428
  console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
2970
3429
  console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
2971
- console.log(" commit [-m \"message\"] version the app's source in the workspace without publishing");
2972
- console.log(" log [slug] the app's source version history (commits + publishes)");
2973
- console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app in Studio, or attach to a running session (sandboxed data)");
2974
- console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, publish results)");
3430
+ console.log(" log [slug] the app's source version history (one row per save)");
3431
+ console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app's session, or attach to a running one (live data, auto-auth)");
3432
+ console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, save results)");
2975
3433
  console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
2976
3434
  console.log(" components [query] list the curated component catalog");
2977
3435
  console.log(" docs <name> view a component's source before installing");
@@ -2981,9 +3439,10 @@ switch (command) {
2981
3439
  console.log(" install install app dependencies");
2982
3440
  console.log(" build production build (vite, via monty)");
2983
3441
  console.log(" typecheck typecheck (builds first if needed)");
2984
- console.log(" deploy build + upload this app straight to Live");
3442
+ console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main` (build + typecheck gate it)");
3443
+ console.log(" deploy alias of save");
2985
3444
  console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
2986
- console.log(" schema pull [slug] [--force] regenerate monty.config.ts from the app's stored manifest (schema-as-data)");
3445
+ console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
2987
3446
  console.log(" skills install/refresh the agent build skill");
2988
3447
  process.exit(command ? 1 : 0);
2989
3448
  }