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