@montytools/cli 0.2.9 → 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 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
- function loadConfig() {
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
- return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
41
- } catch {
42
- return null;
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 host = flag("host") ?? DEFAULT_HOST;
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(CONFIG_PATH, JSON.stringify({ host, key }, null, 2) + "\n");
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} (key saved to ~/.monty/config.json)`);
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.2";
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 && !semverLt(v, MIN_SDK)) return;
455
- console.log(
456
- v
457
- ? `sdk: installed ${v} < required ${MIN_SDK} — updating @montytools/sdk`
458
- : "sdk: @montytools/sdk missing — installing",
459
- );
460
- const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
461
- run(appDir, "sdk-update", [pm, "install", "@montytools/sdk@latest"],
462
- "Could not update @montytools/sdk. Run the install manually in the app folder, then retry.");
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
- // Development mode: vite locally + a Cloudflare quick tunnel registered as
467
- // the app's DEV channel, so workspace admins see the app live (HMR included)
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 for real.
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 DEV schema channel: heartbeats carry the compiled schema, and edits
496
- // to monty.config.ts are re-compiled (softly) so schema changes reach the
497
- // platform within one heartbeat. Publish owns the PROD schema.
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 — dev schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
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 workspace now serves the new version (dev session continues)"
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 dev mode disabled (run `monty login`)");
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 — dev mode registered on localhost (visible on this machine's browser only)");
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 — dev mode registered on localhost (visible on this machine's browser only)");
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 is live there while this runs; click Publish to ship`
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" (live records untouched)`);
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
- // dev mode then falls back to localhost-only registration.
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 skips workspace review — the usual flow is `monty dev` + the Publish button in the workspace.");
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
- async function compileConfig(appDir, { soft = false } = {}) {
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, "compile-entry.mjs");
925
- const out = join(tmpDir, "compile-out.mjs");
1080
+ const entry = join(tmpDir, "fn-worker-entry.mjs");
1081
+ const out = join(tmpDir, "fn-worker-out.mjs");
926
1082
  writeFileSync(entry, [
927
- `import { app } from "../monty.config";`,
928
- `import { compileApp } from "@montytools/sdk/compile";`,
929
- `process.stdout.write(JSON.stringify(compileApp(app)));`,
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
- target: "node22",
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
- const result = spawnSync(process.execPath, [out], { encoding: "utf8" });
943
- if (result.status !== 0) {
944
- if (soft) {
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 JSON.parse(result.stdout);
1118
+ return { code: readFileSync(out, "utf8"), fns };
951
1119
  } catch (e) {
952
- if (e?.errors) {
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("CONFIG_COMPILE_FAILED", `esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}`);
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
  }
@@ -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.9",
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 authenticated, sandboxed
24
- data). **You are done when `monty dev` prints the workspace dev URL and
25
- the app works — leave `monty dev` running.** Publishing to the whole
26
- workspace is the OWNER'S click (Publish in the workspace menu bar);
27
- **never run `monty deploy` yourself** unless the user explicitly asks
28
- for a direct production deploy.
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 deploy.** `monty dev` writes to a `#dev` sandbox — live
45
- team records are never touched, so exercise the app for real.
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 locally (auto-picks a free port), sandboxed data, auto-auth |
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; app appears in the workspace |
63
+ | `monty deploy` | build + typecheck + upload straight to Live (owner escape hatch) |
58
64
  | `monty skills` | (re)install this skill for your agent |
@@ -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
- **Development vs production:** while `monty dev` runs, the app is live in
127
- the workspace in DEV mode (workspace admins only, tunneled, `#dev` sandboxed
128
- data). Shipping to the whole team is the owner's **Publish** click in the
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 dev mode;
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
- live sandboxed data, and read your errors in the `monty dev` terminal
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
- Dev writes go to a sandboxed `#dev` namespace inside your real workspace —
142
- iterate freely, live app data is untouched. The "DEV · … · sandbox data" badge
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
 
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.1.2",
13
+ "@montytools/sdk": "^0.1.3",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",