@montytools/cli 0.2.8 → 0.2.10
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 +254 -55
- package/lib/compile.mjs +67 -0
- package/package.json +8 -2
- package/skills/monty-build/SKILL.md +16 -10
- package/template/AGENTS.md +9 -8
- package/template/package.json +1 -1
package/bin/monty.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { basename, dirname, join, relative } from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { createInterface } from "node:readline/promises";
|
|
15
15
|
import { CATALOG, REGISTRIES } from "./catalog.mjs";
|
|
16
|
+
import { CompileError, compileAppConfig } from "../lib/compile.mjs";
|
|
16
17
|
|
|
17
18
|
const CONFIG_DIR = join(homedir(), ".monty");
|
|
18
19
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
@@ -35,17 +36,73 @@ function fail(code, fix) {
|
|
|
35
36
|
process.exit(1);
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
// ── Profiles & the .montyrc directory pin ──────────────────────────────────
|
|
40
|
+
// One key PER HOST (like kubectl contexts): logging into the local platform
|
|
41
|
+
// host never clobbers the prod key. Which host a command targets resolves,
|
|
42
|
+
// in order: MONTY_HOST env → nearest .montyrc walking up from cwd (a
|
|
43
|
+
// committable, secret-free { "host": "…" } — the venv-style pin: the
|
|
44
|
+
// platform repo carries one pointing at the local host, so every monty
|
|
45
|
+
// command inside it targets the dev platform) → the config's defaultHost →
|
|
46
|
+
// usemonty.dev. Keys always come from the per-host profile store.
|
|
47
|
+
|
|
48
|
+
function findMontyrcHost(startDir) {
|
|
49
|
+
let dir = startDir;
|
|
50
|
+
for (let i = 0; i < 30; i++) {
|
|
51
|
+
const p = join(dir, ".montyrc");
|
|
52
|
+
if (existsSync(p)) {
|
|
53
|
+
try {
|
|
54
|
+
const host = JSON.parse(readFileSync(p, "utf8")).host;
|
|
55
|
+
if (typeof host === "string" && /^https?:\/\//.test(host)) {
|
|
56
|
+
return host.replace(/\/+$/, "");
|
|
57
|
+
}
|
|
58
|
+
} catch { /* malformed pin — ignore and keep walking */ }
|
|
59
|
+
}
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) break;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Reads config.json in either shape: legacy { host, key } (migrated on the
|
|
68
|
+
// next login) or { defaultHost, profiles: { [host]: { key } } }.
|
|
69
|
+
function normalizedConfig() {
|
|
70
|
+
let raw = null;
|
|
39
71
|
try {
|
|
40
|
-
|
|
41
|
-
} catch {
|
|
42
|
-
|
|
72
|
+
raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
73
|
+
} catch { /* not logged in anywhere yet */ }
|
|
74
|
+
if (!raw) return { defaultHost: null, profiles: {} };
|
|
75
|
+
if (raw.profiles && typeof raw.profiles === "object") {
|
|
76
|
+
return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
|
|
43
77
|
}
|
|
78
|
+
const legacyHost = (raw.host ?? DEFAULT_HOST).replace(/\/+$/, "");
|
|
79
|
+
return {
|
|
80
|
+
defaultHost: legacyHost,
|
|
81
|
+
profiles: raw.key ? { [legacyHost]: { key: raw.key } } : {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveHost() {
|
|
86
|
+
return (
|
|
87
|
+
(process.env.MONTY_HOST ?? "").replace(/\/+$/, "") ||
|
|
88
|
+
findMontyrcHost(process.cwd()) ||
|
|
89
|
+
normalizedConfig().defaultHost ||
|
|
90
|
+
DEFAULT_HOST
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Same call-site shape as before ({ host, key }) — host is now always the
|
|
95
|
+
// RESOLVED host and key is that host's profile key (null when not logged in
|
|
96
|
+
// to this host, even if other profiles exist).
|
|
97
|
+
function loadConfig() {
|
|
98
|
+
const host = resolveHost();
|
|
99
|
+
return { host, key: normalizedConfig().profiles[host]?.key ?? null };
|
|
44
100
|
}
|
|
45
101
|
|
|
46
102
|
// ── monty login ────────────────────────────────────────────────────────────
|
|
47
103
|
async function login() {
|
|
48
|
-
const
|
|
104
|
+
const pinnedHost = findMontyrcHost(process.cwd());
|
|
105
|
+
const host = (flag("host") ?? resolveHost()).replace(/\/+$/, "");
|
|
49
106
|
let key = flag("key");
|
|
50
107
|
if (!key) {
|
|
51
108
|
// Browser flow: loopback callback + explicit Authorize click in the host.
|
|
@@ -61,10 +118,23 @@ async function login() {
|
|
|
61
118
|
if (!/^mk_[0-9a-f]{48}$/.test(key)) {
|
|
62
119
|
fail("INVALID_CLI_KEY", `That does not look like a Monty CLI key (mk_ + 48 hex chars). Create one at ${host}/cli-auth.`);
|
|
63
120
|
}
|
|
121
|
+
// Merge into the per-host profile store — other hosts' keys survive.
|
|
122
|
+
// defaultHost only moves when the user chose the host explicitly (flag) or
|
|
123
|
+
// no pin drove the choice; a .montyrc-pinned login stays scoped to its
|
|
124
|
+
// directory and leaves the machine-wide default alone.
|
|
125
|
+
const cfg = normalizedConfig();
|
|
126
|
+
cfg.profiles[host] = { key };
|
|
127
|
+
const defaultHost =
|
|
128
|
+
flag("host") || !pinnedHost || !cfg.defaultHost ? host : cfg.defaultHost;
|
|
64
129
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
65
|
-
writeFileSync(
|
|
130
|
+
writeFileSync(
|
|
131
|
+
CONFIG_PATH,
|
|
132
|
+
JSON.stringify({ defaultHost, profiles: cfg.profiles }, null, 2) + "\n",
|
|
133
|
+
);
|
|
66
134
|
mkdirSync(MONTY_HOME, { recursive: true });
|
|
67
|
-
console.log(`logged-in: ${host} (
|
|
135
|
+
console.log(`logged-in: ${host} (profile saved to ~/.monty/config.json)`);
|
|
136
|
+
const others = Object.keys(cfg.profiles).filter((h) => h !== host);
|
|
137
|
+
if (others.length) console.log(`profiles: ${host} (active here)${pinnedHost ? " via .montyrc" : ""}, ${others.join(", ")}`);
|
|
68
138
|
console.log(`apps home: ${MONTY_HOME}`);
|
|
69
139
|
installSkills({ silent: false });
|
|
70
140
|
}
|
|
@@ -427,7 +497,8 @@ async function freePort(start) {
|
|
|
427
497
|
// Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
|
|
428
498
|
// minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
|
|
429
499
|
// SDK's vite plugin) and upgrades the app automatically before dev/deploy.
|
|
430
|
-
const MIN_SDK = "0.1.
|
|
500
|
+
const MIN_SDK = "0.1.3";
|
|
501
|
+
const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
|
|
431
502
|
|
|
432
503
|
function installedSdkVersion(appDir) {
|
|
433
504
|
try {
|
|
@@ -451,23 +522,46 @@ function semverLt(a, b) {
|
|
|
451
522
|
|
|
452
523
|
function ensureSdk(appDir) {
|
|
453
524
|
const v = installedSdkVersion(appDir);
|
|
454
|
-
if (v
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
525
|
+
if (!v || semverLt(v, MIN_SDK)) {
|
|
526
|
+
console.log(
|
|
527
|
+
v
|
|
528
|
+
? `sdk: installed ${v} < required ${MIN_SDK} — updating @montytools/sdk`
|
|
529
|
+
: "sdk: @montytools/sdk missing — installing",
|
|
530
|
+
);
|
|
531
|
+
const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
|
|
532
|
+
run(appDir, "sdk-update", [pm, "install", "@montytools/sdk@latest"],
|
|
533
|
+
"Could not update @montytools/sdk. Run the install manually in the app folder, then retry.");
|
|
534
|
+
}
|
|
535
|
+
syncSdkViteCache(appDir);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function syncSdkViteCache(appDir) {
|
|
539
|
+
const v = installedSdkVersion(appDir);
|
|
540
|
+
if (!v) return;
|
|
541
|
+
const stampDir = join(appDir, ".monty");
|
|
542
|
+
const stampPath = join(stampDir, SDK_VITE_CACHE_STAMP);
|
|
543
|
+
let stamped = null;
|
|
544
|
+
try {
|
|
545
|
+
stamped = readFileSync(stampPath, "utf8").trim();
|
|
546
|
+
} catch {
|
|
547
|
+
stamped = null;
|
|
548
|
+
}
|
|
549
|
+
if (stamped === v) return;
|
|
550
|
+
const cacheDir = join(appDir, "node_modules", ".vite");
|
|
551
|
+
if (existsSync(cacheDir)) {
|
|
552
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
553
|
+
console.log(`vite: cleared dependency cache for @montytools/sdk ${v}`);
|
|
554
|
+
}
|
|
555
|
+
mkdirSync(stampDir, { recursive: true });
|
|
556
|
+
writeFileSync(stampPath, `${v}\n`);
|
|
463
557
|
}
|
|
464
558
|
|
|
465
559
|
// ── monty dev ──────────────────────────────────────────────────────────────
|
|
466
|
-
//
|
|
467
|
-
// the app's
|
|
560
|
+
// Puts the app in Studio: vite locally + a Cloudflare quick tunnel registered
|
|
561
|
+
// as the app's STUDIO channel, so workspace admins see the app (HMR included)
|
|
468
562
|
// at usemonty.dev while it runs. Data is #dev-sandboxed automatically (vite
|
|
469
563
|
// dev build). The heartbeat doubles as the publish poll: when an owner
|
|
470
|
-
// clicks Publish in the workspace, this process builds + uploads
|
|
564
|
+
// clicks Publish in the workspace, this process builds + uploads to Live.
|
|
471
565
|
async function dev() {
|
|
472
566
|
const appDir = requireAppDir("dev");
|
|
473
567
|
ensureSdk(appDir);
|
|
@@ -492,9 +586,9 @@ async function dev() {
|
|
|
492
586
|
const sessionId = `dev_${randomBytes(16).toString("hex")}`;
|
|
493
587
|
const buildFile = join(appDir, ".monty", "build");
|
|
494
588
|
const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
|
|
495
|
-
// The
|
|
496
|
-
// to monty.config.ts are re-compiled (softly) so schema changes reach
|
|
497
|
-
// platform within one heartbeat. Publish owns the
|
|
589
|
+
// The STUDIO schema channel: heartbeats carry the compiled schema, and
|
|
590
|
+
// edits to monty.config.ts are re-compiled (softly) so schema changes reach
|
|
591
|
+
// the platform within one heartbeat. Publish owns the LIVE schema.
|
|
498
592
|
let currentMeta = meta;
|
|
499
593
|
const configPath = join(appDir, "monty.config.ts");
|
|
500
594
|
let configMtime = statSync(configPath).mtimeMs;
|
|
@@ -508,7 +602,7 @@ async function dev() {
|
|
|
508
602
|
if (fresh) {
|
|
509
603
|
currentMeta = fresh;
|
|
510
604
|
console.log(
|
|
511
|
-
`schema: monty.config.ts changed —
|
|
605
|
+
`schema: monty.config.ts changed — Studio schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
|
|
512
606
|
);
|
|
513
607
|
}
|
|
514
608
|
} catch { /* transient fs hiccup — next beat retries */ }
|
|
@@ -560,6 +654,9 @@ async function dev() {
|
|
|
560
654
|
icon: currentMeta.icon,
|
|
561
655
|
buildId,
|
|
562
656
|
schemaJson: currentMeta.schemaJson,
|
|
657
|
+
// The expose block rides the same compile as the schema — the dev
|
|
658
|
+
// visitor preview is gated on it (devExposureJson).
|
|
659
|
+
exposure: currentMeta.exposure,
|
|
563
660
|
}),
|
|
564
661
|
signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
|
|
565
662
|
});
|
|
@@ -583,7 +680,7 @@ async function dev() {
|
|
|
583
680
|
pub.on("exit", (code) => {
|
|
584
681
|
console.log(
|
|
585
682
|
code === 0
|
|
586
|
-
? "publish: done — the
|
|
683
|
+
? "publish: done — the app is Live for the workspace (Studio session continues)"
|
|
587
684
|
: "publish: FAILED — fix the errors above, then click Publish again",
|
|
588
685
|
);
|
|
589
686
|
resolve(undefined);
|
|
@@ -600,7 +697,7 @@ async function dev() {
|
|
|
600
697
|
|
|
601
698
|
async function startDevSession() {
|
|
602
699
|
if (!cfg?.key) {
|
|
603
|
-
console.log("dev: not logged in — workspace
|
|
700
|
+
console.log("dev: not logged in — workspace Studio disabled (run `monty login`)");
|
|
604
701
|
return;
|
|
605
702
|
}
|
|
606
703
|
await clearDevSession();
|
|
@@ -618,7 +715,7 @@ async function dev() {
|
|
|
618
715
|
if (ended || version !== tunnelVersion) return "superseded";
|
|
619
716
|
if (!dnsLive) {
|
|
620
717
|
if (initial) {
|
|
621
|
-
console.log("tunnel: DNS never propagated —
|
|
718
|
+
console.log("tunnel: DNS never propagated — Studio registered on localhost (visible on this machine's browser only)");
|
|
622
719
|
} else {
|
|
623
720
|
console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
|
|
624
721
|
}
|
|
@@ -647,13 +744,13 @@ async function dev() {
|
|
|
647
744
|
console.log(`tunnel: ${t.url}`);
|
|
648
745
|
await activateTunnelUrl(t.url, { initial: true });
|
|
649
746
|
} else {
|
|
650
|
-
console.log("tunnel: unavailable —
|
|
747
|
+
console.log("tunnel: unavailable — Studio registered on localhost (visible on this machine's browser only)");
|
|
651
748
|
}
|
|
652
749
|
}
|
|
653
750
|
const registered = await heartbeat(originUrl, { claim: true });
|
|
654
751
|
console.log(
|
|
655
752
|
registered
|
|
656
|
-
? `studio: ${host}/studio/${meta.slug} — your app
|
|
753
|
+
? `studio: ${host}/studio/${meta.slug} — your app runs there while this is up; click Publish to go Live`
|
|
657
754
|
: `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
|
|
658
755
|
);
|
|
659
756
|
hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
|
|
@@ -665,7 +762,7 @@ async function dev() {
|
|
|
665
762
|
process.stdout.write(text);
|
|
666
763
|
if (!announced && /localhost:\d+/.test(text)) {
|
|
667
764
|
announced = true;
|
|
668
|
-
console.log(`data: sandboxed to "${meta.slug}#dev" (
|
|
765
|
+
console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
|
|
669
766
|
console.log(`ready: http://localhost:${port}`);
|
|
670
767
|
void startDevSession();
|
|
671
768
|
}
|
|
@@ -719,7 +816,7 @@ async function waitForDns(hostname) {
|
|
|
719
816
|
|
|
720
817
|
// Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
|
|
721
818
|
// binary on first use). Resolves with the public URL, or null on failure —
|
|
722
|
-
//
|
|
819
|
+
// Studio then falls back to localhost-only registration.
|
|
723
820
|
function startTunnel(port, onUrlChange) {
|
|
724
821
|
return new Promise((resolve) => {
|
|
725
822
|
let child;
|
|
@@ -862,12 +959,56 @@ async function docs() {
|
|
|
862
959
|
`Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
|
|
863
960
|
}
|
|
864
961
|
|
|
962
|
+
// ── monty secret ─────────────────────────────────────────────────────────
|
|
963
|
+
// `monty secret set KEY [value]` / `monty secret rm KEY` — per-app
|
|
964
|
+
// server-function secrets. The value goes to Cloudflare's per-script secrets
|
|
965
|
+
// layer for THIS app's fn-worker via the host; never stored by Monty, never
|
|
966
|
+
// readable back. Read from the arg, then a TTY prompt, then stdin (piping).
|
|
967
|
+
async function secret() {
|
|
968
|
+
const appDir = requireAppDir("secret");
|
|
969
|
+
const meta = await compileConfig(appDir);
|
|
970
|
+
const config = loadConfig();
|
|
971
|
+
if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
972
|
+
const [sub, name] = rest.filter((a) => !a.startsWith("-"));
|
|
973
|
+
if ((sub !== "set" && sub !== "rm") || !name) {
|
|
974
|
+
fail("SECRET_USAGE", "Usage: `monty secret set KEY [value]` (omit value to be prompted / piped) or `monty secret rm KEY`.");
|
|
975
|
+
}
|
|
976
|
+
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(name)) {
|
|
977
|
+
fail("BAD_SECRET_NAME", "Secret names are UPPER_SNAKE_CASE (A-Z, 0-9, _), starting with a letter — e.g. OPENAI_API_KEY.");
|
|
978
|
+
}
|
|
979
|
+
const del = sub === "rm";
|
|
980
|
+
let value;
|
|
981
|
+
if (!del) {
|
|
982
|
+
value = rest.filter((a) => !a.startsWith("-"))[2];
|
|
983
|
+
if (value === undefined) {
|
|
984
|
+
if (process.stdin.isTTY) {
|
|
985
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
986
|
+
value = (await rl.question(`value for ${name}: `)).trim();
|
|
987
|
+
rl.close();
|
|
988
|
+
} else {
|
|
989
|
+
value = readFileSync(0, "utf8").trim(); // piped
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (!value) fail("EMPTY_SECRET", "No value provided.");
|
|
993
|
+
}
|
|
994
|
+
const res = await fetch(`${config.host}/api/secret`, {
|
|
995
|
+
method: "POST",
|
|
996
|
+
headers: { authorization: `Bearer ${config.key}`, "content-type": "application/json" },
|
|
997
|
+
body: JSON.stringify({ slug: meta.slug, name, ...(del ? { delete: true } : { value }) }),
|
|
998
|
+
});
|
|
999
|
+
const body = await res.json().catch(() => null);
|
|
1000
|
+
if (!res.ok || !body?.ok) {
|
|
1001
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not change the secret.");
|
|
1002
|
+
}
|
|
1003
|
+
console.log(del ? `secret: removed ${name} from ${meta.slug}` : `secret: set ${name} on ${meta.slug} (write-only; not readable back)`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
865
1006
|
// ── monty deploy ───────────────────────────────────────────────────────────
|
|
866
1007
|
async function deploy() {
|
|
867
1008
|
const appDir = requireAppDir("deploy");
|
|
868
1009
|
ensureSdk(appDir);
|
|
869
1010
|
if (!rest.includes("--from-dev")) {
|
|
870
|
-
console.log("note: direct deploy
|
|
1011
|
+
console.log("note: direct deploy ships straight to Live, skipping workspace review — the usual flow is `monty dev` (Studio) + the Publish button in the workspace.");
|
|
871
1012
|
}
|
|
872
1013
|
const config = loadConfig();
|
|
873
1014
|
if (!config?.key) {
|
|
@@ -895,6 +1036,15 @@ async function deploy() {
|
|
|
895
1036
|
meta.buildId = readFileSync(buildFile, "utf8").trim();
|
|
896
1037
|
}
|
|
897
1038
|
const form = new FormData();
|
|
1039
|
+
// 3a) Server functions (optional): bundle server/index.ts into one worker
|
|
1040
|
+
// script and ride the SAME deploy. The manifest (fns) goes in meta so
|
|
1041
|
+
// the router gates /__monty/fn/* without a lookup.
|
|
1042
|
+
const serverBundle = await bundleServerFns(appDir);
|
|
1043
|
+
if (serverBundle) {
|
|
1044
|
+
meta.fns = serverBundle.fns;
|
|
1045
|
+
form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
|
|
1046
|
+
console.log(`fns: bundled ${serverBundle.fns.length} server function(s) (${serverBundle.fns.join(", ")})`);
|
|
1047
|
+
}
|
|
898
1048
|
form.set("monty", JSON.stringify(meta));
|
|
899
1049
|
let total = 0;
|
|
900
1050
|
for (const file of files) {
|
|
@@ -917,49 +1067,95 @@ async function deploy() {
|
|
|
917
1067
|
console.log(`deployed: ${body.url} (version ${body.version})`);
|
|
918
1068
|
}
|
|
919
1069
|
|
|
920
|
-
|
|
1070
|
+
// Bundle server/index.ts (if present) into ONE Worker script: a generated
|
|
1071
|
+
// entry wraps the app's exports with @montytools/sdk/fn-worker's makeFnWorker,
|
|
1072
|
+
// esbuild bundles it for workerd. node: imports are rejected at compile time
|
|
1073
|
+
// — Live runs on Cloudflare Workers, not Node. Returns { code, fns } or null.
|
|
1074
|
+
async function bundleServerFns(appDir) {
|
|
1075
|
+
const serverEntry = join(appDir, "server", "index.ts");
|
|
1076
|
+
if (!existsSync(serverEntry)) return null;
|
|
921
1077
|
const { build } = await import("esbuild");
|
|
922
1078
|
const tmpDir = join(appDir, ".monty");
|
|
923
1079
|
mkdirSync(tmpDir, { recursive: true });
|
|
924
|
-
const entry = join(tmpDir, "
|
|
925
|
-
const out = join(tmpDir, "
|
|
1080
|
+
const entry = join(tmpDir, "fn-worker-entry.mjs");
|
|
1081
|
+
const out = join(tmpDir, "fn-worker-out.mjs");
|
|
926
1082
|
writeFileSync(entry, [
|
|
927
|
-
`import
|
|
928
|
-
`import {
|
|
929
|
-
`
|
|
1083
|
+
`import * as appFns from "../server/index";`,
|
|
1084
|
+
`import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
|
|
1085
|
+
`export default makeFnWorker(appFns);`,
|
|
930
1086
|
].join("\n"));
|
|
1087
|
+
// Fail the deploy if server code reaches for Node built-ins — a Worker
|
|
1088
|
+
// can't run them, and a silent runtime crash on Live is the worst outcome.
|
|
1089
|
+
const banPlatformImports = {
|
|
1090
|
+
name: "ban-platform-imports",
|
|
1091
|
+
setup(b) {
|
|
1092
|
+
b.onResolve({ filter: /^node:/ }, (a) => ({
|
|
1093
|
+
errors: [{ text: `server/ cannot import "${a.path}" — server functions run on Cloudflare Workers (Web APIs only: fetch, crypto, URL…), not Node.` }],
|
|
1094
|
+
}));
|
|
1095
|
+
b.onResolve({ filter: /^cloudflare:/ }, (a) => ({
|
|
1096
|
+
errors: [{ text: `server/ cannot import "${a.path}" — Cloudflare bindings are platform-private. Use ctx.records, ctx.secrets, fetch, crypto, and other Web APIs.` }],
|
|
1097
|
+
}));
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
let fns;
|
|
931
1101
|
try {
|
|
932
1102
|
await build({
|
|
933
1103
|
entryPoints: [entry],
|
|
934
1104
|
outfile: out,
|
|
935
1105
|
bundle: true,
|
|
936
|
-
platform: "node",
|
|
937
1106
|
format: "esm",
|
|
938
|
-
|
|
1107
|
+
platform: "browser",
|
|
1108
|
+
target: "es2022",
|
|
1109
|
+
conditions: ["workerd", "worker", "browser"],
|
|
939
1110
|
absWorkingDir: appDir,
|
|
940
1111
|
logLevel: "silent",
|
|
1112
|
+
plugins: [banPlatformImports],
|
|
941
1113
|
});
|
|
942
|
-
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
|
|
946
|
-
return null;
|
|
947
|
-
}
|
|
948
|
-
fail("CONFIG_COMPILE_FAILED", `monty.config.ts threw while loading:\n${result.stderr}\nFix the config (it must only call defineApp with zod tables).`);
|
|
1114
|
+
fns = discoverFnExports(serverEntry);
|
|
1115
|
+
if (fns.length === 0) {
|
|
1116
|
+
fail("NO_FN_EXPORTS", "server/index.ts exists but exports no async functions. Export named functions like `export async function score(args, ctx) {…}`, or remove the folder.");
|
|
949
1117
|
}
|
|
950
|
-
return
|
|
1118
|
+
return { code: readFileSync(out, "utf8"), fns };
|
|
951
1119
|
} catch (e) {
|
|
952
|
-
if (e?.
|
|
1120
|
+
if (e?.code === "NO_FN_EXPORTS") throw e; // fail() already exited; guard for safety
|
|
1121
|
+
const msg = e?.errors?.[0]?.text ?? e?.message ?? String(e);
|
|
1122
|
+
fail("FN_BUNDLE_FAILED", `Could not bundle server/index.ts: ${msg}`);
|
|
1123
|
+
} finally {
|
|
1124
|
+
rmSync(entry, { force: true });
|
|
1125
|
+
rmSync(out, { force: true });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// The manifest = the named exports of server/index.ts, read statically
|
|
1130
|
+
// (regex over `export … function NAME` / `export const NAME =`). onEvent is
|
|
1131
|
+
// included when present but the platform invokes it, not useServerFn.
|
|
1132
|
+
function discoverFnExports(serverEntry) {
|
|
1133
|
+
const src = readFileSync(serverEntry, "utf8");
|
|
1134
|
+
const names = new Set();
|
|
1135
|
+
const re =
|
|
1136
|
+
/export\s+(?:async\s+)?function\s+([a-zA-Z_$][\w$]*)|export\s+const\s+([a-zA-Z_$][\w$]*)\s*=/g;
|
|
1137
|
+
let m;
|
|
1138
|
+
while ((m = re.exec(src))) {
|
|
1139
|
+
const name = m[1] ?? m[2];
|
|
1140
|
+
if (/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name)) names.add(name);
|
|
1141
|
+
}
|
|
1142
|
+
return [...names];
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
|
|
1146
|
+
// Studio heartbeat's last good schema through transient config breakage.
|
|
1147
|
+
async function compileConfig(appDir, { soft = false } = {}) {
|
|
1148
|
+
try {
|
|
1149
|
+
return await compileAppConfig(appDir);
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
if (e instanceof CompileError) {
|
|
953
1152
|
if (soft) {
|
|
954
1153
|
console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
|
|
955
1154
|
return null;
|
|
956
1155
|
}
|
|
957
|
-
fail(
|
|
1156
|
+
fail(e.code, e.fix);
|
|
958
1157
|
}
|
|
959
1158
|
throw e;
|
|
960
|
-
} finally {
|
|
961
|
-
rmSync(entry, { force: true });
|
|
962
|
-
rmSync(out, { force: true });
|
|
963
1159
|
}
|
|
964
1160
|
}
|
|
965
1161
|
|
|
@@ -1033,11 +1229,14 @@ switch (command) {
|
|
|
1033
1229
|
case "deploy":
|
|
1034
1230
|
await deploy();
|
|
1035
1231
|
break;
|
|
1232
|
+
case "secret":
|
|
1233
|
+
await secret();
|
|
1234
|
+
break;
|
|
1036
1235
|
default:
|
|
1037
1236
|
console.log("usage: monty <login|create|current|select|apps|install|dev|build|typecheck|add|components|docs|deploy|skills>");
|
|
1038
1237
|
console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
|
|
1039
1238
|
console.log(" create <slug> [--name N] [--icon I] [--build ID] stamp a new app into ~/Monty/<slug>");
|
|
1040
|
-
console.log(" dev [--port N] run locally, auto-picks a free port (sandboxed data)");
|
|
1239
|
+
console.log(" dev [--port N] run the app in Studio locally, auto-picks a free port (sandboxed data)");
|
|
1041
1240
|
console.log(" add <name...> install curated UI components (see `monty components`)");
|
|
1042
1241
|
console.log(" components [query] list the curated component catalog");
|
|
1043
1242
|
console.log(" docs <name> view a component's source before installing");
|
|
@@ -1047,7 +1246,7 @@ switch (command) {
|
|
|
1047
1246
|
console.log(" install install app dependencies");
|
|
1048
1247
|
console.log(" build production build (vite, via monty)");
|
|
1049
1248
|
console.log(" typecheck typecheck (builds first if needed)");
|
|
1050
|
-
console.log(" deploy build + upload this app");
|
|
1249
|
+
console.log(" deploy build + upload this app straight to Live");
|
|
1051
1250
|
console.log(" skills install/refresh the agent build skill");
|
|
1052
1251
|
process.exit(command ? 1 : 0);
|
|
1053
1252
|
}
|
package/lib/compile.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The ONE config-compile pipeline: esbuild-bundle a temp entry that runs the
|
|
2
|
+
// app's OWN compileApp/zod/sdk instances on monty.config.ts, execute it in a
|
|
3
|
+
// subprocess, parse the emitted metadata. Used by `monty dev`/`monty deploy`
|
|
4
|
+
// (bin/monty.mjs) and the demo harness (scripts/demo.mjs) — one pipeline, so
|
|
5
|
+
// what a demo installs is byte-for-byte what a deploy would send.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
export class CompileError extends Error {
|
|
11
|
+
constructor(code, fix) {
|
|
12
|
+
super(`[${code}] ${fix}`);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.fix = fix;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Compile appDir/monty.config.ts → { slug, name, icon?, schemaJson, exposure? }.
|
|
19
|
+
// Throws CompileError:
|
|
20
|
+
// - CONFIG_BUNDLE_FAILED — esbuild could not bundle (usually unlinked deps)
|
|
21
|
+
// - CONFIG_COMPILE_FAILED — the config threw while loading
|
|
22
|
+
export async function compileAppConfig(appDir) {
|
|
23
|
+
appDir = resolve(appDir); // esbuild requires an absolute absWorkingDir
|
|
24
|
+
// esbuild is a dependency of THIS package (@montytools/cli), so resolution
|
|
25
|
+
// from here works for any caller — no per-script resolution dance.
|
|
26
|
+
const { build } = await import("esbuild");
|
|
27
|
+
const tmpDir = join(appDir, ".monty");
|
|
28
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
29
|
+
const entry = join(tmpDir, "compile-entry.mjs");
|
|
30
|
+
const out = join(tmpDir, "compile-out.mjs");
|
|
31
|
+
writeFileSync(entry, [
|
|
32
|
+
`import { app } from "../monty.config";`,
|
|
33
|
+
`import { compileApp } from "@montytools/sdk/compile";`,
|
|
34
|
+
`process.stdout.write(JSON.stringify(compileApp(app)));`,
|
|
35
|
+
].join("\n"));
|
|
36
|
+
try {
|
|
37
|
+
await build({
|
|
38
|
+
entryPoints: [entry],
|
|
39
|
+
outfile: out,
|
|
40
|
+
bundle: true,
|
|
41
|
+
platform: "node",
|
|
42
|
+
format: "esm",
|
|
43
|
+
target: "node22",
|
|
44
|
+
absWorkingDir: appDir,
|
|
45
|
+
logLevel: "silent",
|
|
46
|
+
});
|
|
47
|
+
const result = spawnSync(process.execPath, [out], { encoding: "utf8" });
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
throw new CompileError(
|
|
50
|
+
"CONFIG_COMPILE_FAILED",
|
|
51
|
+
`monty.config.ts threw while loading:\n${result.stderr}\nFix the config (it must only call defineApp with zod tables).`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return JSON.parse(result.stdout);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e?.errors) {
|
|
57
|
+
throw new CompileError(
|
|
58
|
+
"CONFIG_BUNDLE_FAILED",
|
|
59
|
+
`esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}\nRun \`pnpm install\` so the app's deps link, then retry.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw e;
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(entry, { force: true });
|
|
65
|
+
rmSync(out, { force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@montytools/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/TomasMonty/monty-v2.git",
|
|
7
|
+
"directory": "packages/cli"
|
|
8
|
+
},
|
|
4
9
|
"type": "module",
|
|
5
10
|
"bin": {
|
|
6
11
|
"monty": "./bin/monty.mjs"
|
|
7
12
|
},
|
|
8
13
|
"files": [
|
|
9
14
|
"bin",
|
|
15
|
+
"lib",
|
|
10
16
|
"template",
|
|
11
17
|
"skills"
|
|
12
18
|
],
|
|
@@ -15,7 +21,7 @@
|
|
|
15
21
|
},
|
|
16
22
|
"scripts": {
|
|
17
23
|
"prepack": "node scripts/bundle-template.mjs",
|
|
18
|
-
"typecheck": "node --check bin/monty.mjs",
|
|
24
|
+
"typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs",
|
|
19
25
|
"postinstall": "node bin/postinstall.mjs"
|
|
20
26
|
},
|
|
21
27
|
"dependencies": {
|
|
@@ -11,6 +11,11 @@ platform's job. The complete contract lives in the app's own `AGENTS.md`
|
|
|
11
11
|
(nearest-file-wins — read it before writing code). This skill is the map, not
|
|
12
12
|
the territory.
|
|
13
13
|
|
|
14
|
+
Every app has two states: **Live** (published — what members and visitors
|
|
15
|
+
see) and **Studio** (development — your running `monty dev` session on
|
|
16
|
+
`#dev`-sandboxed data). Those are the only names for them; "dev"/"prod"
|
|
17
|
+
mean something else on this platform.
|
|
18
|
+
|
|
14
19
|
## Rules
|
|
15
20
|
|
|
16
21
|
1. **Folders are managed.** Apps live in `~/Monty/<slug>`. `monty current`
|
|
@@ -20,12 +25,12 @@ the territory.
|
|
|
20
25
|
(if the prompt includes a `build id`, pass it: `--build <id>` — the
|
|
21
26
|
workspace's New app screen tracks your progress live) →
|
|
22
27
|
`monty install` → edit `monty.config.ts` (zod tables) + `src/routes/` →
|
|
23
|
-
verify with `monty dev` (auto-port, already
|
|
24
|
-
data). **You are done when `monty dev` prints
|
|
25
|
-
the app works — leave `monty dev`
|
|
26
|
-
|
|
27
|
-
**never run `monty deploy` yourself** unless the
|
|
28
|
-
for a direct
|
|
28
|
+
verify with `monty dev` — the app's Studio state (auto-port, already
|
|
29
|
+
authenticated, sandboxed data). **You are done when `monty dev` prints
|
|
30
|
+
the workspace Studio URL and the app works — leave `monty dev`
|
|
31
|
+
running.** Taking the app Live is the OWNER'S click (Publish in the
|
|
32
|
+
workspace menu bar); **never run `monty deploy` yourself** unless the
|
|
33
|
+
user explicitly asks for a direct Live deploy.
|
|
29
34
|
3. **Everything through the CLI.** `monty install`, `monty build`,
|
|
30
35
|
`monty typecheck`, `monty dev`, `monty deploy` — never run vite, tsc,
|
|
31
36
|
pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
|
|
@@ -41,8 +46,9 @@ the territory.
|
|
|
41
46
|
7. **Errors are instructions.** Every failure prints
|
|
42
47
|
`[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
|
|
43
48
|
Typecheck failures block deploy by design.
|
|
44
|
-
8. **Verify before
|
|
45
|
-
team records are never touched, so exercise the app for
|
|
49
|
+
8. **Verify before publish.** `monty dev` writes to the `#dev` Studio
|
|
50
|
+
sandbox — Live team records are never touched, so exercise the app for
|
|
51
|
+
real.
|
|
46
52
|
|
|
47
53
|
## CLI reference
|
|
48
54
|
|
|
@@ -52,7 +58,7 @@ the territory.
|
|
|
52
58
|
| `monty create <slug>` | stamp a new app into `~/Monty/<slug>` |
|
|
53
59
|
| `monty current` / `select` / `apps` | where am I / jump to app / list local |
|
|
54
60
|
| `monty install` / `build` / `typecheck` | full lifecycle via the CLI — no raw pnpm/vite/tsc |
|
|
55
|
-
| `monty dev` | run
|
|
61
|
+
| `monty dev` | run the app in Studio (auto-picks a free port), sandboxed data, auto-auth |
|
|
56
62
|
| `monty add <name…>` | install curated shadcn components |
|
|
57
|
-
| `monty deploy` | build + typecheck + upload
|
|
63
|
+
| `monty deploy` | build + typecheck + upload straight to Live (owner escape hatch) |
|
|
58
64
|
| `monty skills` | (re)install this skill for your agent |
|
package/template/AGENTS.md
CHANGED
|
@@ -123,11 +123,12 @@ monty dev # Vite + HMR, auto-picks a free port and prints it
|
|
|
123
123
|
Headless? Verify with `monty build` then `monty typecheck` (typecheck builds
|
|
124
124
|
first when needed — the build generates `src/routeTree.gen.ts`).
|
|
125
125
|
|
|
126
|
-
**
|
|
127
|
-
the
|
|
128
|
-
data).
|
|
126
|
+
**Studio vs Live:** every Monty app has two states. While `monty dev` runs,
|
|
127
|
+
the app is in **Studio** — visible in the workspace to admins only
|
|
128
|
+
(tunneled, `#dev` sandboxed data). **Live** is the published state the
|
|
129
|
+
whole team sees. Going Live is the owner's **Publish** click in the
|
|
129
130
|
workspace menu bar — it signals your running `monty dev`, which builds,
|
|
130
|
-
typechecks, and uploads. You are done when the app works in
|
|
131
|
+
typechecks, and uploads. You are done when the app works in Studio;
|
|
131
132
|
leave `monty dev` running and let the owner publish. Only run
|
|
132
133
|
`monty deploy` directly if the user explicitly asks.
|
|
133
134
|
|
|
@@ -135,12 +136,12 @@ leave `monty dev` running and let the owner publish. Only run
|
|
|
135
136
|
`http://localhost:5173` is ALREADY AUTHENTICATED — no sign-in screen (the dev
|
|
136
137
|
server mints short-lived workspace tokens from the CLI login). Point
|
|
137
138
|
Playwright or any browser automation at it, click through your app against
|
|
138
|
-
|
|
139
|
+
reactive sandboxed data, and read your errors in the `monty dev` terminal
|
|
139
140
|
(`[browser:error] …` lines). Edit → HMR → look → fix: verify your own work.
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
iterate freely,
|
|
143
|
-
confirms it.
|
|
142
|
+
Studio writes go to a sandboxed `#dev` namespace inside your real workspace —
|
|
143
|
+
iterate freely, Live app data is untouched. The "STUDIO · … · sandbox data"
|
|
144
|
+
badge confirms it.
|
|
144
145
|
|
|
145
146
|
## Modeling tips
|
|
146
147
|
|
package/template/package.json
CHANGED