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