@montytools/cli 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/monty.mjs CHANGED
@@ -4,8 +4,10 @@
4
4
  // deterministic final line (`deployed: …` / `error: …`).
5
5
 
6
6
  import { spawn, spawnSync } from "node:child_process";
7
- import { randomBytes } from "node:crypto";
8
- import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { appendFileSync, closeSync, cpSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, symlinkSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { StringDecoder } from "node:string_decoder";
9
11
  import { createServer } from "node:http";
10
12
  import { connect as netConnect } from "node:net";
11
13
  import { homedir } from "node:os";
@@ -13,15 +15,36 @@ import { basename, dirname, join, relative } from "node:path";
13
15
  import { fileURLToPath } from "node:url";
14
16
  import { createInterface } from "node:readline/promises";
15
17
  import { CATALOG, REGISTRIES } from "./catalog.mjs";
18
+ import { CompileError, compileAppConfig } from "../lib/compile.mjs";
16
19
 
17
20
  const CONFIG_DIR = join(homedir(), ".monty");
18
21
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
19
22
  const DEFAULT_HOST = "https://usemonty.dev";
20
23
  const DEV_SESSION_HEARTBEAT_MS = 30_000;
21
24
  const DEV_SESSION_REQUEST_TIMEOUT_MS = 10_000;
22
- // Every app's source lives in one predictable place. `monty create` stamps
23
- // here by default (override with --dir) and `monty login` provisions it.
24
- const MONTY_HOME = join(homedir(), "Monty");
25
+ // The local session contract (.monty/dev.json + dev.log): the running dev
26
+ // shell advertises itself so a second `monty dev` (an agent, or the Monty
27
+ // desktop) attaches instead of superseding, and `monty logs` reads the log.
28
+ const DEV_JSON_STALE_MS = 90_000; // freshness window for dev.json.updatedAt
29
+ const DEV_JSON_TOUCH_MS = 15_000; // dedicated updatedAt cadence (NOT the heartbeat — that starts minutes late, or never when logged out)
30
+ const DEV_LOG_MAX_BYTES = 8 * 1024 * 1024; // rotate dev.log at 8 MiB (disk bound ≈ 16 MiB with dev.log.1)
31
+ const LOGS_DEFAULT_LINES = 50;
32
+ const LOGS_POLL_MS = 300;
33
+ const ATTACH_TAIL_LINES = 10;
34
+ const TAKEOVER_WAIT_MS = 5_000; // grace per phase (SIGTERM, then SIGKILL)
35
+ // eslint-disable-next-line no-control-regex -- dev.log is ANSI-free by contract
36
+ const ANSI_RE = new RegExp(
37
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
38
+ "g",
39
+ );
40
+ // Every app's source lives in one predictable, hidden place: `monty create`
41
+ // registers the app first and the server-minted id names the folder
42
+ // (~/.monty/apps/<id>) — id-keyed because slugs may be renamed later; the
43
+ // `id` stamped into monty.config.ts is the durable identity. `monty login`
44
+ // provisions the home; --dir overrides per create. Pre-id apps in the legacy
45
+ // visible home (~/Monty) keep working — every scan reads both.
46
+ const MONTY_HOME = join(CONFIG_DIR, "apps");
47
+ const LEGACY_MONTY_HOME = join(homedir(), "Monty");
25
48
 
26
49
  const [, , command, ...rest] = process.argv;
27
50
 
@@ -35,17 +58,73 @@ function fail(code, fix) {
35
58
  process.exit(1);
36
59
  }
37
60
 
38
- function loadConfig() {
61
+ // ── Profiles & the .montyrc directory pin ──────────────────────────────────
62
+ // One key PER HOST (like kubectl contexts): logging into the local platform
63
+ // host never clobbers the prod key. Which host a command targets resolves,
64
+ // in order: MONTY_HOST env → nearest .montyrc walking up from cwd (a
65
+ // committable, secret-free { "host": "…" } — the venv-style pin: the
66
+ // platform repo carries one pointing at the local host, so every monty
67
+ // command inside it targets the dev platform) → the config's defaultHost →
68
+ // usemonty.dev. Keys always come from the per-host profile store.
69
+
70
+ function findMontyrcHost(startDir) {
71
+ let dir = startDir;
72
+ for (let i = 0; i < 30; i++) {
73
+ const p = join(dir, ".montyrc");
74
+ if (existsSync(p)) {
75
+ try {
76
+ const host = JSON.parse(readFileSync(p, "utf8")).host;
77
+ if (typeof host === "string" && /^https?:\/\//.test(host)) {
78
+ return host.replace(/\/+$/, "");
79
+ }
80
+ } catch { /* malformed pin — ignore and keep walking */ }
81
+ }
82
+ const parent = dirname(dir);
83
+ if (parent === dir) break;
84
+ dir = parent;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // Reads config.json in either shape: legacy { host, key } (migrated on the
90
+ // next login) or { defaultHost, profiles: { [host]: { key } } }.
91
+ function normalizedConfig() {
92
+ let raw = null;
39
93
  try {
40
- return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
41
- } catch {
42
- return null;
94
+ raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
95
+ } catch { /* not logged in anywhere yet */ }
96
+ if (!raw) return { defaultHost: null, profiles: {} };
97
+ if (raw.profiles && typeof raw.profiles === "object") {
98
+ return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
43
99
  }
100
+ const legacyHost = (raw.host ?? DEFAULT_HOST).replace(/\/+$/, "");
101
+ return {
102
+ defaultHost: legacyHost,
103
+ profiles: raw.key ? { [legacyHost]: { key: raw.key } } : {},
104
+ };
105
+ }
106
+
107
+ function resolveHost() {
108
+ return (
109
+ (process.env.MONTY_HOST ?? "").replace(/\/+$/, "") ||
110
+ findMontyrcHost(process.cwd()) ||
111
+ normalizedConfig().defaultHost ||
112
+ DEFAULT_HOST
113
+ );
114
+ }
115
+
116
+ // Same call-site shape as before ({ host, key }) — host is now always the
117
+ // RESOLVED host and key is that host's profile key (null when not logged in
118
+ // to this host, even if other profiles exist).
119
+ function loadConfig() {
120
+ const host = resolveHost();
121
+ return { host, key: normalizedConfig().profiles[host]?.key ?? null };
44
122
  }
45
123
 
46
124
  // ── monty login ────────────────────────────────────────────────────────────
47
125
  async function login() {
48
- const host = flag("host") ?? DEFAULT_HOST;
126
+ const pinnedHost = findMontyrcHost(process.cwd());
127
+ const host = (flag("host") ?? resolveHost()).replace(/\/+$/, "");
49
128
  let key = flag("key");
50
129
  if (!key) {
51
130
  // Browser flow: loopback callback + explicit Authorize click in the host.
@@ -61,10 +140,23 @@ async function login() {
61
140
  if (!/^mk_[0-9a-f]{48}$/.test(key)) {
62
141
  fail("INVALID_CLI_KEY", `That does not look like a Monty CLI key (mk_ + 48 hex chars). Create one at ${host}/cli-auth.`);
63
142
  }
143
+ // Merge into the per-host profile store — other hosts' keys survive.
144
+ // defaultHost only moves when the user chose the host explicitly (flag) or
145
+ // no pin drove the choice; a .montyrc-pinned login stays scoped to its
146
+ // directory and leaves the machine-wide default alone.
147
+ const cfg = normalizedConfig();
148
+ cfg.profiles[host] = { key };
149
+ const defaultHost =
150
+ flag("host") || !pinnedHost || !cfg.defaultHost ? host : cfg.defaultHost;
64
151
  mkdirSync(CONFIG_DIR, { recursive: true });
65
- writeFileSync(CONFIG_PATH, JSON.stringify({ host, key }, null, 2) + "\n");
152
+ writeFileSync(
153
+ CONFIG_PATH,
154
+ JSON.stringify({ defaultHost, profiles: cfg.profiles }, null, 2) + "\n",
155
+ );
66
156
  mkdirSync(MONTY_HOME, { recursive: true });
67
- console.log(`logged-in: ${host} (key saved to ~/.monty/config.json)`);
157
+ console.log(`logged-in: ${host} (profile saved to ~/.monty/config.json)`);
158
+ const others = Object.keys(cfg.profiles).filter((h) => h !== host);
159
+ if (others.length) console.log(`profiles: ${host} (active here)${pinnedHost ? " via .montyrc" : ""}, ${others.join(", ")}`);
68
160
  console.log(`apps home: ${MONTY_HOME}`);
69
161
  installSkills({ silent: false });
70
162
  }
@@ -227,12 +319,24 @@ function readSlugFromConfig(dir) {
227
319
  }
228
320
  }
229
321
 
230
- function listLocalApps() {
231
- if (!existsSync(MONTY_HOME)) return [];
232
- return readdirSync(MONTY_HOME)
233
- .map((name) => join(MONTY_HOME, name))
322
+ function readIdFromConfig(dir) {
323
+ try {
324
+ return /^[ \t]*id:\s*"([^"]+)"/m.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ function scanAppsHome(root) {
331
+ if (!existsSync(root)) return [];
332
+ return readdirSync(root)
333
+ .map((name) => join(root, name))
234
334
  .filter((p) => existsSync(join(p, "monty.config.ts")))
235
- .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p) }));
335
+ .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
336
+ }
337
+
338
+ function listLocalApps() {
339
+ return [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
236
340
  }
237
341
 
238
342
  function current() {
@@ -241,8 +345,10 @@ function current() {
241
345
  fail("NOT_IN_APP", `You are not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one.`);
242
346
  }
243
347
  console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
348
+ const id = readIdFromConfig(root);
349
+ if (id) console.log(`id: ${id}`);
244
350
  console.log(`path: ${root}`);
245
- if (!root.startsWith(MONTY_HOME)) {
351
+ if (!root.startsWith(MONTY_HOME) && !root.startsWith(LEGACY_MONTY_HOME)) {
246
352
  console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
247
353
  }
248
354
  }
@@ -253,10 +359,10 @@ function select() {
253
359
  fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
254
360
  }
255
361
  const apps = listLocalApps();
256
- const hit = apps.find((a) => a.slug === slug || basename(a.path) === slug);
362
+ const hit = apps.find((a) => a.slug === slug || a.id === slug || basename(a.path) === slug);
257
363
  if (!hit) {
258
364
  const known = apps.map((a) => a.slug).join(", ") || "(none)";
259
- fail("APP_NOT_LOCAL", `No local source for "${slug}" in ${MONTY_HOME}. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
365
+ fail("APP_NOT_LOCAL", `No local source for "${slug}" on this machine. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
260
366
  }
261
367
  // Bare path on stdout so command substitution works: cd "$(monty select x)"
262
368
  console.log(hit.path);
@@ -271,22 +377,267 @@ function apps() {
271
377
  for (const a of local) console.log(`${a.slug}\t${a.path}`);
272
378
  }
273
379
 
380
+ // Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
381
+ // tar.gz buffer + its sha256 — the snapshot unit `monty commit` and deploys
382
+ // both upload. gzip runs with -n (no embedded timestamp) so an UNCHANGED
383
+ // tree packs to identical bytes — that's what makes "nothing to commit"
384
+ // detectable by hash. Returns null when packing fails; { tooLarge } past
385
+ // the 10MB cap.
386
+ function packSource(appDir) {
387
+ mkdirSync(join(appDir, ".monty"), { recursive: true });
388
+ const srcTar = join(appDir, ".monty", "source-upload.tar.gz");
389
+ const excludes = ["./node_modules", "./dist", "./.monty", "./.git", "./.env.local", "./release"]
390
+ .map((p) => `--exclude ${JSON.stringify(p)}`)
391
+ .join(" ");
392
+ const packRes =
393
+ process.platform === "win32"
394
+ ? spawnSync("tar", ["-czf", srcTar, "--exclude", "./node_modules", "--exclude", "./dist", "--exclude", "./.monty", "--exclude", "./.git", "--exclude", "./.env.local", "--exclude", "./release", "-C", appDir, "."], { stdio: "pipe" })
395
+ : spawnSync("sh", ["-c", `tar -cf - ${excludes} -C ${JSON.stringify(appDir)} . | gzip -n > ${JSON.stringify(srcTar)}`], { stdio: "pipe" });
396
+ if (packRes.status !== 0) {
397
+ rmSync(srcTar, { force: true });
398
+ return null;
399
+ }
400
+ const buf = readFileSync(srcTar);
401
+ rmSync(srcTar, { force: true });
402
+ if (buf.byteLength > 10 * 1024 * 1024) return { tooLarge: true };
403
+ return { buf, hash: createHash("sha256").update(buf).digest("hex") };
404
+ }
405
+
406
+ function readSlug(appDir) {
407
+ try {
408
+ return /slug:\s*"([^"]+)"/.exec(readFileSync(join(appDir, "monty.config.ts"), "utf8"))?.[1] ?? null;
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ // ── monty commit ───────────────────────────────────────────────────────────
415
+ // Version the app's source WITHOUT publishing: pack the tree, upload it as
416
+ // one line of history. Git commit with everything stripped except "track
417
+ // versions" — no branches, no diffs, no local repo; history lives in the
418
+ // workspace and survives this folder.
419
+ async function commit() {
420
+ const appDir = requireAppDir("commit");
421
+ const slug = readSlug(appDir);
422
+ if (!slug) fail("NO_SLUG", "monty.config.ts has no slug — fix the config, then retry.");
423
+ const { host, key } = loadConfig();
424
+ if (!key) fail("NOT_LOGGED_IN", "Committing stores the snapshot in your workspace. Run `monty login` first.");
425
+ const mIdx = rest.indexOf("-m");
426
+ const message = flag("message") ?? (mIdx >= 0 ? rest[mIdx + 1] : undefined) ?? "checkpoint";
427
+ const packed = packSource(appDir);
428
+ if (packed === null) fail("PACK_FAILED", "Packing the source failed (tar error). Retry; check the folder is readable.");
429
+ if (packed.tooLarge) fail("SOURCE_TOO_LARGE", "The source tree exceeds 10 MB (node_modules/dist excluded). Remove large assets, then retry.");
430
+ try {
431
+ const stamp = JSON.parse(readFileSync(join(appDir, ".monty", "source.json"), "utf8"));
432
+ if (stamp.hash === packed.hash) {
433
+ console.log(`nothing to commit — source unchanged since ${packed.hash.slice(0, 7)}`);
434
+ return;
435
+ }
436
+ } catch {
437
+ /* no stamp yet — first commit from this folder */
438
+ }
439
+ const form = new FormData();
440
+ form.set("monty", JSON.stringify({ slug, hash: packed.hash, message }));
441
+ form.set("source", new Blob([packed.buf]), "source.tar.gz");
442
+ const res = await fetch(`${host}/api/source`, {
443
+ method: "POST",
444
+ headers: { authorization: `Bearer ${key}` },
445
+ body: form,
446
+ });
447
+ const body = await res.json().catch(() => null);
448
+ if (!res.ok || !body?.ok) {
449
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Uploading the snapshot failed — check the connection and retry.");
450
+ }
451
+ writeFileSync(
452
+ join(appDir, ".monty", "source.json"),
453
+ JSON.stringify({ hash: packed.hash, syncedAt: Date.now() }) + "\n",
454
+ );
455
+ console.log(`committed: ${packed.hash.slice(0, 7)} "${message}" (${(packed.buf.byteLength / 1024).toFixed(0)} KB)`);
456
+ }
457
+
458
+ // ── monty log ──────────────────────────────────────────────────────────────
459
+ // The app's version history, newest first. Commits and publishes share one
460
+ // timeline. (Not `monty logs` — that tails the dev shell.)
461
+ async function versionsLog() {
462
+ const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
463
+ if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
464
+ const { host, key } = loadConfig();
465
+ if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
466
+ const res = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
467
+ headers: { authorization: `Bearer ${key}` },
468
+ });
469
+ const body = await res.json().catch(() => null);
470
+ if (!res.ok || !body?.ok) {
471
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not list versions — check the connection and `monty login`.");
472
+ }
473
+ if (body.versions.length === 0) {
474
+ console.log(`no versions of "${slug}" yet — \`monty commit\` or a publish creates the first one.`);
475
+ return;
476
+ }
477
+ for (const v of body.versions) {
478
+ const when = new Date(v.createdAt).toISOString().slice(0, 16).replace("T", " ");
479
+ console.log(`${v.hash.slice(0, 7)} ${when} ${v.published ? "[published] " : ""}${v.message}`);
480
+ }
481
+ console.log(`\nrestore one: monty pull ${slug} --version <hash> [--force]`);
482
+ }
483
+
484
+ // ── monty pull ─────────────────────────────────────────────────────────────
485
+ // Restore an app's published source snapshot onto this machine. Every
486
+ // `monty deploy`/publish uploads the source tree beside the bundle; pull is
487
+ // how a second machine (or one that lost the folder) gets the code back.
488
+ // Refuses to touch an existing folder without --force — it may hold
489
+ // unpublished work the snapshot would destroy.
490
+ async function pull() {
491
+ const slug = rest.find((a) => !a.startsWith("--"));
492
+ if (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
493
+ fail("INVALID_SLUG", "Usage: monty pull <slug> [--force]");
494
+ }
495
+ const { host, key } = loadConfig();
496
+ if (!key) {
497
+ fail("NOT_LOGGED_IN", `Pulling needs your workspace (${host}). Run \`monty login\` first.`);
498
+ }
499
+ const appsRes = await fetch(`${host}/api/apps`, { headers: { authorization: `Bearer ${key}` } });
500
+ const appsBody = await appsRes.json().catch(() => null);
501
+ if (!appsRes.ok || !appsBody?.ok) {
502
+ fail(appsBody?.code ?? `HTTP_${appsRes.status}`, appsBody?.fix ?? "Could not list workspace apps — check the connection and `monty login`.");
503
+ }
504
+ const app = appsBody.apps.find((a) => a.slug === slug);
505
+ if (!app) {
506
+ fail("APP_NOT_FOUND", `No app "${slug}" in this workspace. \`monty apps\` lists what exists.`);
507
+ }
508
+ // --version <hash-prefix>: restore a specific snapshot from `monty log`
509
+ // instead of the newest one. Prefixes resolve against the history list.
510
+ const versionFlag = flag("version");
511
+ let expectedHash = app.sourceHash;
512
+ let downloadUrl = `${host}/api/source?slug=${slug}`;
513
+ if (versionFlag) {
514
+ const vres = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
515
+ headers: { authorization: `Bearer ${key}` },
516
+ });
517
+ const vbody = await vres.json().catch(() => null);
518
+ if (!vres.ok || !vbody?.ok) {
519
+ fail(vbody?.code ?? `HTTP_${vres.status}`, vbody?.fix ?? "Could not list versions — retry.");
520
+ }
521
+ const matches = vbody.versions.filter((v) => v.hash.startsWith(versionFlag));
522
+ if (matches.length === 0) {
523
+ fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty log ${slug}\` lists what exists.`);
524
+ }
525
+ if (matches.length > 1) {
526
+ fail("VERSION_AMBIGUOUS", `"${versionFlag}" matches ${matches.length} versions — use more characters of the hash.`);
527
+ }
528
+ expectedHash = matches[0].hash;
529
+ downloadUrl = `${host}/api/source?slug=${slug}&hash=${expectedHash}`;
530
+ } else if (!app.sourceHash) {
531
+ fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each publish and \`monty commit\`. Run either once from the machine that has the source, then pull works everywhere.`);
532
+ }
533
+ const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
534
+ if (existsSync(target) && !rest.includes("--force")) {
535
+ fail("DIR_EXISTS", `${target} already exists and may hold unpublished work. Compare it with the published version first; re-run with --force to REPLACE it with the snapshot.`);
536
+ }
537
+
538
+ console.log(`pull: ${slug}${versionFlag ? ` @ ${expectedHash.slice(0, 7)}` : ""} <- ${host}`);
539
+ const res = await fetch(downloadUrl, {
540
+ headers: { authorization: `Bearer ${key}` },
541
+ });
542
+ if (!res.ok) {
543
+ const b = await res.json().catch(() => null);
544
+ fail(b?.code ?? `HTTP_${res.status}`, b?.fix ?? "Downloading the snapshot failed — retry.");
545
+ }
546
+ const buf = Buffer.from(await res.arrayBuffer());
547
+ const hash = createHash("sha256").update(buf).digest("hex");
548
+ if (hash !== expectedHash) {
549
+ fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, republish the app from a machine that has the source.");
550
+ }
551
+
552
+ // Extract into a staging folder, then move into place — a failed extract
553
+ // never leaves a half-written app folder.
554
+ const staging = `${target}.pull-tmp`;
555
+ rmSync(staging, { recursive: true, force: true });
556
+ mkdirSync(staging, { recursive: true });
557
+ const tarFile = join(staging, ".source.tar.gz");
558
+ writeFileSync(tarFile, buf);
559
+ const untar = spawnSync("tar", ["-xzf", tarFile, "-C", staging], { stdio: "pipe" });
560
+ rmSync(tarFile, { force: true });
561
+ if (untar.status !== 0) {
562
+ rmSync(staging, { recursive: true, force: true });
563
+ fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, republish the app.");
564
+ }
565
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
566
+ renameSync(staging, target);
567
+
568
+ // Same follow-ups as create: client env from the platform + the sync stamp.
569
+ try {
570
+ const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
571
+ writeFileSync(
572
+ join(target, ".env.local"),
573
+ `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
574
+ );
575
+ } catch {
576
+ console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
577
+ }
578
+ mkdirSync(join(target, ".monty"), { recursive: true });
579
+ writeFileSync(
580
+ join(target, ".monty", "source.json"),
581
+ JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
582
+ );
583
+ console.log(`pulled: ${target}`);
584
+ console.log("next: `monty install`, then `monty dev`.");
585
+ }
586
+
274
587
  // ── monty create ───────────────────────────────────────────────────────────
275
588
  async function create() {
276
589
  const slug = rest.find((a) => !a.startsWith("--"));
277
- if (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
278
- fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens (e.g. "standup-notes").');
590
+ if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
591
+ fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens, max 64 chars (e.g. "standup-notes").');
592
+ }
593
+ // An app with this slug already on disk means create is the wrong verb —
594
+ // fail BEFORE registering, and never suggest deleting anything: the folder
595
+ // may hold real, uncommitted work.
596
+ const dupe = listLocalApps().find((a) => a.slug === slug);
597
+ if (dupe) {
598
+ fail("APP_EXISTS", `"${slug}" already exists on this machine at ${dupe.path}. Keep working on it there (cd "$(monty select ${slug})"); pick a different slug for a new app.`);
279
599
  }
280
600
  const name =
281
601
  flag("name") ??
282
602
  slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
283
603
  const icon = flag("icon") ?? "layout-grid";
284
- // Apps live in ~/Monty/<slug> unless --dir points elsewhere.
604
+
605
+ // Step 0: register the app — the DB mints the id that names the local
606
+ // folder and rides monty.config.ts, and it arbitrates slug uniqueness
607
+ // workspace-wide. Creating is therefore online + logged-in, by design.
608
+ const { host, key } = loadConfig();
609
+ if (!key) {
610
+ fail("NOT_LOGGED_IN", `Creating an app registers it in your workspace (${host}). Run \`monty login\` first.`);
611
+ }
612
+ let appId;
613
+ try {
614
+ const r = await fetch(`${host}/api/apps`, {
615
+ method: "POST",
616
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
617
+ body: JSON.stringify({ slug, name, icon }),
618
+ signal: AbortSignal.timeout(15_000),
619
+ });
620
+ const data = await r.json().catch(() => null);
621
+ // The id names a folder and is stamped into a TS file — accept only a
622
+ // plain Convex-id-shaped token, never anything path- or quote-capable.
623
+ if (!r.ok || !data?.ok || typeof data.appId !== "string" || !/^[a-z0-9]{10,64}$/i.test(data.appId)) {
624
+ fail(
625
+ data?.code ?? "CREATE_FAILED",
626
+ data?.fix ?? `Registering the app with ${host} failed (status ${r.status}). Retry; if it persists, run \`monty login\` again.`,
627
+ );
628
+ }
629
+ appId = data.appId;
630
+ } catch {
631
+ fail("HOST_UNREACHABLE", `Could not reach ${host} — creating an app registers it in your workspace, so it needs the network. Check your connection and retry.`);
632
+ }
633
+ console.log(`registered: ${slug} (id ${appId})`);
634
+
635
+ // Source lands in the id-keyed hidden home unless --dir points elsewhere.
285
636
  const target = flag("dir")
286
637
  ? join(process.cwd(), flag("dir"))
287
- : join(MONTY_HOME, slug);
638
+ : join(MONTY_HOME, appId);
288
639
  if (existsSync(target)) {
289
- fail("DIR_EXISTS", `${target} already exists. Pick another slug or remove the directory.`);
640
+ fail("DIR_EXISTS", `${target} already exists. Remove it (a previous create for "${slug}" left it behind), then retry.`);
290
641
  }
291
642
  mkdirSync(dirname(target), { recursive: true });
292
643
 
@@ -310,12 +661,13 @@ async function create() {
310
661
  },
311
662
  });
312
663
 
313
- // Stamp identity into the copied files.
664
+ // Stamp identity into the copied files. The id line is INSERTED (the
665
+ // template ships without one — only real creates have a server id).
314
666
  const configPath = join(target, "monty.config.ts");
315
667
  writeFileSync(
316
668
  configPath,
317
669
  readFileSync(configPath, "utf8")
318
- .replace(/slug: "[^"]*"/, `slug: "${slug}"`)
670
+ .replace(/^([ \t]*)slug: "[^"]*"/m, `$1id: "${appId}",\n$1slug: "${slug}"`)
319
671
  .replace(/name: "[^"]*"/, `name: "${name}"`)
320
672
  .replace(/icon: "[^"]*"/, `icon: "${icon}"`),
321
673
  );
@@ -329,8 +681,27 @@ async function create() {
329
681
  readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
330
682
  );
331
683
 
684
+ // The user's brief (--description, e.g. from the desktop's create dialog)
685
+ // goes to the TOP of AGENTS.md — agent harnesses can't be handed an initial
686
+ // prompt portably, but they all read the project instruction file. CLAUDE.md
687
+ // symlinks to AGENTS.md so claude sees the same brief codex/opencode do.
688
+ const description = flag("description");
689
+ const agentsPath = join(target, "AGENTS.md");
690
+ if (description?.trim() && existsSync(agentsPath)) {
691
+ const brief = `# What to build: ${name}\n\n${description.trim()}\n\nThat brief is the product goal. Everything below is the platform contract for building it.\n\n---\n\n`;
692
+ writeFileSync(agentsPath, brief + readFileSync(agentsPath, "utf8"));
693
+ console.log("brief: AGENTS.md carries the app description");
694
+ }
695
+ if (!existsSync(join(target, "CLAUDE.md"))) {
696
+ try {
697
+ symlinkSync("AGENTS.md", join(target, "CLAUDE.md"));
698
+ } catch {
699
+ // Symlinks need privileges on Windows — Claude Code's @import reads the same.
700
+ writeFileSync(join(target, "CLAUDE.md"), "@AGENTS.md\n");
701
+ }
702
+ }
703
+
332
704
  // Client config comes from the platform — public values, no dashboard trip.
333
- const host = loadConfig()?.host ?? DEFAULT_HOST;
334
705
  try {
335
706
  const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
336
707
  writeFileSync(
@@ -348,18 +719,15 @@ async function create() {
348
719
  if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
349
720
  mkdirSync(join(target, ".monty"), { recursive: true });
350
721
  writeFileSync(join(target, ".monty", "build"), buildId + "\n");
351
- const cfg = loadConfig();
352
- if (cfg?.key) {
353
- try {
354
- await fetch(`${cfg.host ?? DEFAULT_HOST}/api/build`, {
355
- method: "POST",
356
- headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
357
- body: JSON.stringify({ buildId, slug }),
358
- });
359
- console.log("build: workspace notifiedthe New app screen is following along");
360
- } catch {
361
- /* progress signal only — never block create */
362
- }
722
+ try {
723
+ await fetch(`${host}/api/build`, {
724
+ method: "POST",
725
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
726
+ body: JSON.stringify({ buildId, slug }),
727
+ });
728
+ console.log("build: workspace notified — the New app screen is following along");
729
+ } catch {
730
+ /* progress signal only never block create */
363
731
  }
364
732
  }
365
733
 
@@ -427,7 +795,8 @@ async function freePort(start) {
427
795
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
428
796
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
429
797
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
430
- const MIN_SDK = "0.1.2";
798
+ const MIN_SDK = "0.1.3";
799
+ const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
431
800
 
432
801
  function installedSdkVersion(appDir) {
433
802
  try {
@@ -451,25 +820,440 @@ function semverLt(a, b) {
451
820
 
452
821
  function ensureSdk(appDir) {
453
822
  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.");
823
+ if (!v || semverLt(v, MIN_SDK)) {
824
+ console.log(
825
+ v
826
+ ? `sdk: installed ${v} < required ${MIN_SDK} — updating @montytools/sdk`
827
+ : "sdk: @montytools/sdk missing — installing",
828
+ );
829
+ const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
830
+ run(appDir, "sdk-update", [pm, "install", "@montytools/sdk@latest"],
831
+ "Could not update @montytools/sdk. Run the install manually in the app folder, then retry.");
832
+ }
833
+ syncSdkViteCache(appDir);
834
+ }
835
+
836
+ function syncSdkViteCache(appDir) {
837
+ const v = installedSdkVersion(appDir);
838
+ if (!v) return;
839
+ const stampDir = join(appDir, ".monty");
840
+ const stampPath = join(stampDir, SDK_VITE_CACHE_STAMP);
841
+ let stamped = null;
842
+ try {
843
+ stamped = readFileSync(stampPath, "utf8").trim();
844
+ } catch {
845
+ stamped = null;
846
+ }
847
+ if (stamped === v) return;
848
+ const cacheDir = join(appDir, "node_modules", ".vite");
849
+ if (existsSync(cacheDir)) {
850
+ rmSync(cacheDir, { recursive: true, force: true });
851
+ console.log(`vite: cleared dependency cache for @montytools/sdk ${v}`);
852
+ }
853
+ mkdirSync(stampDir, { recursive: true });
854
+ writeFileSync(stampPath, `${v}\n`);
855
+ }
856
+
857
+ // ── the local dev-session contract ─────────────────────────────────────────
858
+ // The running dev shell advertises itself in <app>/.monty/dev.json (atomic
859
+ // tmp+rename writes, a 15s touch timer drives updatedAt) and tees everything
860
+ // it prints into <app>/.monty/dev.log. That file pair is the same-machine
861
+ // contract shared by a second `monty dev` (attaches instead of superseding),
862
+ // `monty logs`, and the Monty desktop. Advisory only — cross-machine
863
+ // arbitration stays with the platform's session lock.
864
+
865
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
866
+
867
+ function devPaths(appDir) {
868
+ const dir = join(appDir, ".monty");
869
+ return { dir, json: join(dir, "dev.json"), log: join(dir, "dev.log"), prevLog: join(dir, "dev.log.1") };
870
+ }
871
+
872
+ async function readDevJson(appDir) {
873
+ const { json } = devPaths(appDir);
874
+ for (let attempt = 0; attempt < 2; attempt++) {
875
+ try {
876
+ return JSON.parse(readFileSync(json, "utf8"));
877
+ } catch (e) {
878
+ if (e.code === "ENOENT") return null;
879
+ await sleep(50); // mid-rename window — settle and retry once
880
+ }
881
+ }
882
+ return { unreadable: true };
883
+ }
884
+
885
+ function pidAlive(pid) {
886
+ try {
887
+ process.kill(pid, 0);
888
+ return true;
889
+ } catch (e) {
890
+ return e.code !== "ESRCH"; // EPERM = exists (another user's process)
891
+ }
892
+ }
893
+
894
+ // LIVE ⇔ pid alive && (updatedAt fresh || vite still answering on the
895
+ // recorded port). The port+HTTP fallback keeps a healthy session attachable
896
+ // right after a laptop wake, before the touch timer's next beat.
897
+ async function checkDevSession(appDir) {
898
+ const s = await readDevJson(appDir);
899
+ if (!s) return { live: false, session: null, reason: "no session" };
900
+ if (s.unreadable) return { live: false, session: null, reason: "unreadable session file" };
901
+ if (!Number.isInteger(s.pid) || s.pid <= 0 || typeof s.sessionId !== "string" || typeof s.updatedAt !== "number") {
902
+ return { live: false, session: s, reason: "malformed session file" };
903
+ }
904
+ if (s.pid === process.pid) return { live: false, session: s, reason: "own pid" };
905
+ if (!pidAlive(s.pid)) return { live: false, session: s, reason: `process ${s.pid} not running` };
906
+ if (Date.now() - s.updatedAt <= DEV_JSON_STALE_MS) return { live: true, session: s };
907
+ if (Number.isInteger(s.port) && ((await portTaken("127.0.0.1", s.port)) || (await portTaken("::1", s.port)))) {
908
+ const probeVite = (origin) =>
909
+ fetch(`${origin}:${s.port}/@vite/client`, { signal: AbortSignal.timeout(1000) })
910
+ .then((r) => r.ok)
911
+ .catch(() => false);
912
+ // Both loopback stacks — a vite bound only to ::1 must still read LIVE.
913
+ if ((await probeVite("http://127.0.0.1")) || (await probeVite("http://[::1]"))) {
914
+ return { live: true, session: s };
915
+ }
916
+ }
917
+ return { live: false, session: s, reason: `not responding (no update for ${Math.round((Date.now() - s.updatedAt) / 1000)}s)` };
918
+ }
919
+
920
+ function readLogTail(appDir, n) {
921
+ try {
922
+ const lines = readFileSync(devPaths(appDir).log, "utf8").split("\n");
923
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
924
+ return lines.slice(-n);
925
+ } catch {
926
+ return [];
927
+ }
928
+ }
929
+
930
+ function fmtDuration(seconds) {
931
+ if (seconds < 60) return `${seconds}s`;
932
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
933
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
934
+ }
935
+
936
+ // dev.json writer for THIS session: atomic tmp+rename, never clobbers or
937
+ // deletes a DIFFERENT session's file (we lost a race / were superseded), and
938
+ // SEALS on removal — a late heartbeat or timer resolving after shutdown must
939
+ // not resurrect a session file for a process that is exiting.
940
+ function makeSessionFile(appDir, sessionId) {
941
+ const { json } = devPaths(appDir);
942
+ const tmp = `${json}.${process.pid}.tmp`;
943
+ let current = null;
944
+ let sealed = false;
945
+ const ownsFile = () => {
946
+ try {
947
+ const onDisk = JSON.parse(readFileSync(json, "utf8"));
948
+ return !onDisk?.sessionId || onDisk.sessionId === sessionId;
949
+ } catch {
950
+ return true; // absent or unreadable — ours to (re)write
951
+ }
952
+ };
953
+ return {
954
+ write(patch) {
955
+ if (sealed) return;
956
+ current = { ...(current ?? {}), ...patch, updatedAt: Date.now() };
957
+ if (!ownsFile()) return;
958
+ try {
959
+ mkdirSync(dirname(json), { recursive: true });
960
+ writeFileSync(tmp, JSON.stringify(current, null, 2) + "\n");
961
+ renameSync(tmp, json);
962
+ } catch {
963
+ /* advisory file — the next touch retries */
964
+ }
965
+ },
966
+ remove() {
967
+ sealed = true;
968
+ if (ownsFile()) rmSync(json, { force: true });
969
+ rmSync(tmp, { force: true });
970
+ },
971
+ };
972
+ }
973
+
974
+ // dev.log writer: SYNCHRONOUS appends — a write can never outlive the
975
+ // session (nothing async to race on shutdown), a full disk degrades to
976
+ // silent log loss instead of an uncaught stream error, and fail()'s
977
+ // process.exit cannot drop the final line. Session-start rotation to
978
+ // dev.log.1, 8 MiB size rotation. Each source() is a line assembler that
979
+ // buffers partial chunks (UTF-8-safe across chunk boundaries), strips ANSI,
980
+ // and stamps HH:MM:SS — terminal mirrors always get the ORIGINAL bytes,
981
+ // only the log is normalized.
982
+ function openDevLog(appDir) {
983
+ const { log, prevLog } = devPaths(appDir);
984
+ mkdirSync(dirname(log), { recursive: true });
985
+ try {
986
+ renameSync(log, prevLog);
987
+ } catch {
988
+ /* first session in this folder */
989
+ }
990
+ let bytes = 0;
991
+ let closed = false;
992
+ const write = (line) => {
993
+ if (closed) return;
994
+ try {
995
+ appendFileSync(log, line);
996
+ } catch {
997
+ return; /* the log must never take the session down */
998
+ }
999
+ bytes += Buffer.byteLength(line);
1000
+ if (bytes >= DEV_LOG_MAX_BYTES) {
1001
+ bytes = 0;
1002
+ try {
1003
+ renameSync(log, prevLog);
1004
+ } catch (e) {
1005
+ if (e.code !== "ENOENT") {
1006
+ // Rotation blocked (e.g. dev.log.1 locked on win32): truncate in
1007
+ // place — bounded disk beats an ever-growing log, and followers
1008
+ // recover via their shrink-reopen rule.
1009
+ try {
1010
+ writeFileSync(log, "");
1011
+ } catch {
1012
+ /* still capped at the next cycle */
1013
+ }
1014
+ }
1015
+ }
1016
+ try {
1017
+ appendFileSync(log, `--- log rotated ${new Date().toISOString()} ---\n`);
1018
+ } catch {
1019
+ /* ignore */
1020
+ }
1021
+ }
1022
+ };
1023
+ const stamp = () => new Date().toTimeString().slice(0, 8);
1024
+ const source = (prefix = "") => {
1025
+ const decoder = new StringDecoder("utf8"); // multi-byte chars split across chunks decode intact
1026
+ let buf = "";
1027
+ const emit = (l) => write(`${stamp()} ${prefix}${l.replace(ANSI_RE, "")}\n`);
1028
+ const fn = (chunk) => {
1029
+ buf += typeof chunk === "string" ? chunk : decoder.write(chunk);
1030
+ const lines = buf.split("\n");
1031
+ buf = lines.pop();
1032
+ lines.forEach(emit);
1033
+ };
1034
+ fn.flush = () => {
1035
+ buf += decoder.end();
1036
+ if (buf) {
1037
+ emit(buf);
1038
+ buf = "";
1039
+ }
1040
+ };
1041
+ return fn;
1042
+ };
1043
+ write(`--- monty dev started ${new Date().toISOString()} (pid ${process.pid}) ---\n`);
1044
+ return { source, path: log, close: () => { closed = true; } };
1045
+ }
1046
+
1047
+ // The vite bin as the APP resolves it, spawned via process.execPath — no npx
1048
+ // wrapper, so child.kill() actually kills vite (and win32 avoids the Node>=22
1049
+ // .cmd EINVAL). createRequire walks node_modules upward, so hoisted installs
1050
+ // (workspace apps like demos/) and pnpm symlink layouts all resolve.
1051
+ function resolveViteBin(appDir) {
1052
+ try {
1053
+ const req = createRequire(join(appDir, "package.json"));
1054
+ const pkgPath = req.resolve("vite/package.json");
1055
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1056
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vite;
1057
+ if (bin) return join(dirname(pkgPath), bin);
1058
+ } catch {
1059
+ /* not installed anywhere up the tree */
1060
+ }
1061
+ return null;
1062
+ }
1063
+
1064
+ // Attach output: a fast status glance for agents. Never blocks, always exit 0.
1065
+ function printAttach(appDir, s) {
1066
+ const up = fmtDuration(Math.max(0, Math.round((Date.now() - (s.startedAt ?? s.updatedAt)) / 1000)));
1067
+ console.log(`dev: already running for "${s.slug ?? "?"}" — attached, nothing to start (pid ${s.pid}, up ${up})`);
1068
+ if (s.state === "starting") {
1069
+ console.log("state: starting (vite not ready yet — `monty logs -f` to watch)");
1070
+ } else {
1071
+ console.log(`ready: ${s.appUrl ?? `http://localhost:${s.port}`}`);
1072
+ if (!s.loggedIn) {
1073
+ console.log("state: local-only (not logged in — run `monty login`, then `monty dev --takeover`)");
1074
+ } else if (s.state === "online") {
1075
+ const beat = typeof s.lastHeartbeatAt === "number" ? Math.round((Date.now() - s.lastHeartbeatAt) / 1000) : null;
1076
+ console.log(
1077
+ beat !== null && beat > Math.round(DEV_JSON_STALE_MS / 1000)
1078
+ ? `state: online (no heartbeat for ${beat}s — Studio may show offline)`
1079
+ : `state: online (heartbeat ${beat ?? "?"}s ago)`,
1080
+ );
1081
+ if (s.studioUrl) console.log(`studio: ${s.studioUrl} — your app runs there while this is up; click Publish to go Live`);
1082
+ } else {
1083
+ console.log("state: ready (registering with the workspace — the Studio link appears on the first successful heartbeat)");
1084
+ }
1085
+ }
1086
+ console.log("logs: `monty logs -f` follows output; `monty logs -n 100` shows recent lines");
1087
+ const tail = readLogTail(appDir, ATTACH_TAIL_LINES);
1088
+ if (tail.length) {
1089
+ console.log(`log: last ${tail.length} line(s) of .monty/dev.log`);
1090
+ for (const l of tail) console.log(l);
1091
+ } else {
1092
+ console.log("log: (no log lines yet)");
1093
+ }
1094
+ if (flag("port")) console.log(`note: --port ignored — session already on :${s.port} (\`monty dev --takeover\` to restart)`);
1095
+ }
1096
+
1097
+ // `monty dev --takeover`: stop the recorded session (SIGTERM → SIGKILL; win32
1098
+ // has no graceful phase — process.kill is TerminateProcess, so go straight to
1099
+ // taskkill /T), free the platform lock with the OLD sessionId (a hard-killed
1100
+ // process can't), and hand the folder to a fresh start.
1101
+ async function performTakeover(appDir, s) {
1102
+ const signalPid = (pid, sig) => {
1103
+ try {
1104
+ process.kill(pid, sig);
1105
+ return true;
1106
+ } catch (e) {
1107
+ return e.code === "ESRCH";
1108
+ }
1109
+ };
1110
+ const gone = async () =>
1111
+ !pidAlive(s.pid) &&
1112
+ !(Number.isInteger(s.port) && ((await portTaken("127.0.0.1", s.port)) || (await portTaken("::1", s.port))));
1113
+ const waitGone = async (ms) => {
1114
+ const until = Date.now() + ms;
1115
+ while (Date.now() < until) {
1116
+ if (await gone()) return true;
1117
+ await sleep(200);
1118
+ }
1119
+ return gone();
1120
+ };
1121
+ console.log(`takeover: stopping session pid ${s.pid}…`);
1122
+ if (process.platform === "win32") {
1123
+ spawnSync("taskkill", ["/pid", String(s.pid), "/T", "/F"], { stdio: "ignore" });
1124
+ if (Number.isInteger(s.vitePid)) spawnSync("taskkill", ["/pid", String(s.vitePid), "/T", "/F"], { stdio: "ignore" });
1125
+ } else if (!signalPid(s.pid, "SIGTERM")) {
1126
+ fail("TAKEOVER_FAILED", `The running session (pid ${s.pid}) belongs to another user. Stop it manually, then rerun \`monty dev\`.`);
1127
+ }
1128
+ let ok = await waitGone(TAKEOVER_WAIT_MS);
1129
+ if (!ok && process.platform !== "win32") {
1130
+ console.log("takeover: SIGTERM ignored — escalating to SIGKILL");
1131
+ signalPid(s.pid, "SIGKILL");
1132
+ if (Number.isInteger(s.vitePid)) signalPid(s.vitePid, "SIGKILL");
1133
+ if (Number.isInteger(s.tunnelPid)) signalPid(s.tunnelPid, "SIGKILL");
1134
+ ok = await waitGone(TAKEOVER_WAIT_MS);
1135
+ }
1136
+ if (!ok) {
1137
+ fail("TAKEOVER_PORT_BUSY", `Killed the old session but port ${s.port} is still in use. Wait a few seconds and retry, or run \`monty dev --port <n>\`.`);
1138
+ }
1139
+ console.log(`takeover: session stopped, port ${s.port ?? "?"} free`);
1140
+ // Free the platform lock immediately using the OLD session's id AND host —
1141
+ // after a SIGKILL/taskkill the dead process never got to clear it, the
1142
+ // fresh start would otherwise race the 90s TTL, and the old session may
1143
+ // have been registered against a different host than this shell resolves.
1144
+ const sessionHost = typeof s.host === "string" ? s.host.replace(/\/+$/, "") : null;
1145
+ const key = sessionHost ? (normalizedConfig().profiles[sessionHost]?.key ?? null) : null;
1146
+ if (key && typeof s.slug === "string" && typeof s.sessionId === "string") {
1147
+ try {
1148
+ await fetch(`${sessionHost}/api/dev-session`, {
1149
+ method: "POST",
1150
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
1151
+ body: JSON.stringify({ slug: s.slug, sessionId: s.sessionId, end: true }),
1152
+ signal: AbortSignal.timeout(3000),
1153
+ });
1154
+ } catch {
1155
+ /* the bounded claim window covers the TTL race */
1156
+ }
1157
+ }
1158
+ rmSync(devPaths(appDir).json, { force: true }); // ownership death is proven
1159
+ }
1160
+
1161
+ // A dead CLI pid can leave a LIVE orphaned vite (kill -9 skips endSession).
1162
+ // checkDevSession correctly calls that session stale — so --takeover on a
1163
+ // stale file sweeps the recorded child pids when the recorded port is still
1164
+ // busy, instead of abandoning the port forever.
1165
+ async function sweepOrphans(s) {
1166
+ const portBusy = async () =>
1167
+ Number.isInteger(s.port) && ((await portTaken("127.0.0.1", s.port)) || (await portTaken("::1", s.port)));
1168
+ if (!(await portBusy())) return;
1169
+ console.log(`takeover: dead session left port ${s.port} busy — cleaning up its processes`);
1170
+ const signalPid = (pid, sig) => {
1171
+ try {
1172
+ process.kill(pid, sig);
1173
+ } catch {
1174
+ /* already gone or not ours */
1175
+ }
1176
+ };
1177
+ const kids = [s.vitePid, s.tunnelPid].filter((p) => Number.isInteger(p));
1178
+ for (const pid of kids) {
1179
+ if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
1180
+ else signalPid(pid, "SIGTERM");
1181
+ }
1182
+ let until = Date.now() + TAKEOVER_WAIT_MS;
1183
+ while (Date.now() < until) {
1184
+ if (!(await portBusy())) {
1185
+ console.log(`takeover: port ${s.port} free`);
1186
+ return;
1187
+ }
1188
+ await sleep(200);
1189
+ }
1190
+ if (process.platform !== "win32") {
1191
+ for (const pid of kids) signalPid(pid, "SIGKILL");
1192
+ until = Date.now() + TAKEOVER_WAIT_MS;
1193
+ while (Date.now() < until) {
1194
+ if (!(await portBusy())) {
1195
+ console.log(`takeover: port ${s.port} free`);
1196
+ return;
1197
+ }
1198
+ await sleep(200);
1199
+ }
1200
+ }
1201
+ console.log(`warn: port ${s.port} is still busy after cleanup — picking another port`);
463
1202
  }
464
1203
 
465
1204
  // ── 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)
1205
+ // Puts the app in Studio: vite locally + a Cloudflare quick tunnel registered
1206
+ // as the app's STUDIO channel, so workspace admins see the app (HMR included)
468
1207
  // at usemonty.dev while it runs. Data is #dev-sandboxed automatically (vite
469
1208
  // dev build). The heartbeat doubles as the publish poll: when an owner
470
- // clicks Publish in the workspace, this process builds + uploads for real.
1209
+ // clicks Publish in the workspace, this process builds + uploads to Live.
471
1210
  async function dev() {
472
1211
  const appDir = requireAppDir("dev");
1212
+
1213
+ // Attach check FIRST — before skills/sdk/compile — so a second `monty dev`
1214
+ // is a fast, harmless status glance and can never mutate node_modules
1215
+ // under a live session's vite.
1216
+ const takeover = rest.includes("--takeover");
1217
+ const probe = await checkDevSession(appDir);
1218
+ if (probe.live && !takeover) {
1219
+ printAttach(appDir, probe.session);
1220
+ process.exit(0);
1221
+ }
1222
+ if (probe.live && takeover) {
1223
+ await performTakeover(appDir, probe.session);
1224
+ } else if (probe.session) {
1225
+ console.log(`dev: stale session file from pid ${probe.session.pid} (${probe.reason}) — starting fresh`);
1226
+ if (takeover) {
1227
+ // The stale session's children may have survived it (kill -9 skips
1228
+ // endSession) — sweep them so the recorded port is reclaimable.
1229
+ await sweepOrphans(probe.session);
1230
+ } else if (Number.isInteger(probe.session.port) && (await portTaken("127.0.0.1", probe.session.port))) {
1231
+ console.log(`warn: port ${probe.session.port} is still busy (orphaned vite?) — picking another port; \`monty dev --takeover\` cleans it up`);
1232
+ }
1233
+ rmSync(devPaths(appDir).json, { force: true });
1234
+ } else if (takeover) {
1235
+ console.log("takeover: no running session — starting normally");
1236
+ }
1237
+
1238
+ // Open the log and capture our own output BEFORE the slow steps, so sdk
1239
+ // installs and compile failures land in dev.log for `monty logs`.
1240
+ const logSink = openDevLog(appDir);
1241
+ const cliTee = logSink.source();
1242
+ {
1243
+ const origLog = console.log.bind(console);
1244
+ const origErr = console.error.bind(console);
1245
+ console.log = (...a) => {
1246
+ origLog(...a);
1247
+ cliTee(a.join(" ") + "\n");
1248
+ };
1249
+ console.error = (...a) => {
1250
+ origErr(...a);
1251
+ cliTee(a.join(" ") + "\n");
1252
+ };
1253
+ }
1254
+ console.log("logs: .monty/dev.log (follow with `monty logs -f`)");
1255
+
1256
+ installSkills({ appDir });
473
1257
  ensureSdk(appDir);
474
1258
  const meta = await compileConfig(appDir);
475
1259
  const cfg = loadConfig();
@@ -479,26 +1263,102 @@ async function dev() {
479
1263
  const requested = flag("port");
480
1264
  const port = requested ? Number(requested) : await freePort(5173);
481
1265
 
1266
+ const viteBin = resolveViteBin(appDir);
1267
+ if (!viteBin) {
1268
+ fail("VITE_MISSING", "vite is not installed in this app. Run `monty install`, then `monty dev` again.");
1269
+ }
1270
+
482
1271
  console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
483
- const child = spawn("npx", ["vite", "dev", "--port", String(port), "--strictPort"], {
1272
+ const child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
484
1273
  cwd: appDir,
485
- stdio: ["ignore", "pipe", "inherit"],
1274
+ stdio: ["ignore", "pipe", "pipe"],
486
1275
  });
487
1276
 
488
1277
  let tunnelChild = null;
1278
+ let pubChild = null;
489
1279
  let hbTimer = null;
1280
+ let touchTimer = null;
1281
+ let cronTimer = null;
490
1282
  let publishing = false;
491
1283
  let ended = false;
1284
+ let registeredOnce = false;
1285
+ const devStartedAt = Date.now();
492
1286
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
493
1287
  const buildFile = join(appDir, ".monty", "build");
494
1288
  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.
1289
+ // The STUDIO schema channel: heartbeats carry the compiled schema, and
1290
+ // edits to monty.config.ts are re-compiled (softly) so schema changes reach
1291
+ // the platform within one heartbeat. Publish owns the LIVE schema.
498
1292
  let currentMeta = meta;
499
1293
  const configPath = join(appDir, "monty.config.ts");
500
1294
  let configMtime = statSync(configPath).mtimeMs;
501
1295
 
1296
+ // Advertise this session. The touch timer (not the platform heartbeat,
1297
+ // which starts minutes late or never when logged out) keeps updatedAt
1298
+ // fresh so attach/desktop liveness checks stay honest.
1299
+ const loggedIn = Boolean(cfg?.key);
1300
+ const sf = makeSessionFile(appDir, sessionId);
1301
+ sf.write({
1302
+ version: 1,
1303
+ cli: CLI_VERSION,
1304
+ pid: process.pid,
1305
+ vitePid: child.pid ?? null,
1306
+ tunnelPid: null,
1307
+ port,
1308
+ slug: meta.slug,
1309
+ appDir,
1310
+ host,
1311
+ sessionId,
1312
+ state: "starting",
1313
+ loggedIn,
1314
+ appUrl: `http://localhost:${port}`,
1315
+ tunnelUrl: null,
1316
+ studioUrl: loggedIn ? `${host}/studio/${meta.slug}` : null,
1317
+ previewUrl: loggedIn
1318
+ ? `${host}/studio/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1319
+ : null,
1320
+ publishing: false,
1321
+ lastHeartbeatAt: null,
1322
+ logFile: logSink.path,
1323
+ startedAt: Date.now(),
1324
+ });
1325
+ touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
1326
+
1327
+ // The STUDIO cron runner: the Live counterpart is a real Cloudflare Cron
1328
+ // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1329
+ // `schedule` entries against the UTC clock once per minute and invokes the
1330
+ // fn through the same /__monty/fn runtime (x-monty-schedule marks the
1331
+ // lane, so ctx.viewer matches Live exactly). Config edits hot-apply via
1332
+ // currentMeta. Fire-and-forget: a failing cron fn prints its instruction
1333
+ // here and never blocks the loop.
1334
+ let lastCronMinute = null;
1335
+ function cronTick() {
1336
+ const sched = currentMeta?.schedule;
1337
+ if (!sched || !loggedIn) return;
1338
+ const now = new Date();
1339
+ const minute = Math.floor(now.getTime() / 60_000);
1340
+ if (minute === lastCronMinute) return;
1341
+ lastCronMinute = minute;
1342
+ for (const [fn, expr] of Object.entries(sched)) {
1343
+ if (!cronMatches(expr, now)) continue;
1344
+ console.log(`cron: "${expr}" → ${fn}() (UTC)`);
1345
+ const t0 = Date.now();
1346
+ fetch(`http://localhost:${port}/__monty/fn/${fn}`, {
1347
+ method: "POST",
1348
+ headers: { "content-type": "application/json", "x-monty-schedule": expr },
1349
+ body: "{}",
1350
+ }).then(async (r) => {
1351
+ if (r.ok) {
1352
+ console.log(`cron: ${fn} ok (${Date.now() - t0}ms)`);
1353
+ } else {
1354
+ const e = await r.json().catch(() => null);
1355
+ console.log(`cron: ${fn} failed [${e?.code ?? r.status}] ${e?.fix ?? ""}`);
1356
+ }
1357
+ }).catch((e) => console.log(`cron: ${fn} unreachable — ${e?.message ?? e}`));
1358
+ }
1359
+ }
1360
+ cronTimer = setInterval(cronTick, 20_000);
1361
+
502
1362
  async function refreshSchemaIfChanged() {
503
1363
  try {
504
1364
  const m = statSync(configPath).mtimeMs;
@@ -508,7 +1368,7 @@ async function dev() {
508
1368
  if (fresh) {
509
1369
  currentMeta = fresh;
510
1370
  console.log(
511
- `schema: monty.config.ts changed — dev schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1371
+ `schema: monty.config.ts changed — Studio schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
512
1372
  );
513
1373
  }
514
1374
  } catch { /* transient fs hiccup — next beat retries */ }
@@ -531,7 +1391,15 @@ async function dev() {
531
1391
  if (ended) return;
532
1392
  ended = true;
533
1393
  if (hbTimer) clearInterval(hbTimer);
1394
+ if (touchTimer) clearInterval(touchTimer);
1395
+ if (cronTimer) clearInterval(cronTimer);
1396
+ try { pubChild?.kill(); } catch { /* already gone */ }
534
1397
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1398
+ // vite is a direct child (no npx wrapper), so this actually kills it —
1399
+ // a bare SIGTERM from the desktop must never orphan vite on the port.
1400
+ try { child.kill(); } catch { /* already gone */ }
1401
+ sf.remove();
1402
+ logSink.close();
535
1403
  await clearDevSession();
536
1404
  }
537
1405
 
@@ -539,27 +1407,46 @@ async function dev() {
539
1407
  if (ended) return;
540
1408
  ended = true;
541
1409
  if (hbTimer) clearInterval(hbTimer);
1410
+ if (touchTimer) clearInterval(touchTimer);
1411
+ if (cronTimer) clearInterval(cronTimer);
1412
+ try { pubChild?.kill(); } catch { /* already gone */ }
542
1413
  try { tunnelChild?.kill(); } catch { /* already gone */ }
543
1414
  try { child.kill(); } catch { /* already gone */ }
544
1415
  console.log(`dev-session: superseded — ${fix}`);
1416
+ sf.remove(); // guarded — never deletes the new owner's file
1417
+ logSink.close();
545
1418
  setTimeout(() => process.exit(0), 50);
546
1419
  }
547
1420
 
548
1421
  async function heartbeat(originUrl, { claim = false } = {}) {
1422
+ if (ended) return false; // shutdown already ran — no side effects
549
1423
  await refreshSchemaIfChanged();
550
1424
  try {
1425
+ // Re-read the key EVERY beat: the desktop (or a fresh `monty login`)
1426
+ // may have replaced an expired key while this session runs — the
1427
+ // session must heal itself, not beat forever with a dead key.
1428
+ const liveKey = loadConfig()?.key ?? cfg.key;
551
1429
  const r = await fetch(`${host}/api/dev-session`, {
552
1430
  method: "POST",
553
- headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
1431
+ headers: { authorization: `Bearer ${liveKey}`, "content-type": "application/json" },
554
1432
  body: JSON.stringify({
555
1433
  slug: meta.slug,
556
1434
  tunnelUrl: originUrl,
557
1435
  sessionId,
558
- claim,
1436
+ // Keep claiming until the first successful registration, but only
1437
+ // within the lock's own 90s TTL window: after a takeover/crash the
1438
+ // old lock may linger, and a single failed first beat must not
1439
+ // strand this session into DEV_SESSION_SUPERSEDED against a dead
1440
+ // owner. BOUNDED so a never-registering session (broken network)
1441
+ // can't steal the lock from a newer active session forever.
1442
+ claim: claim || (!registeredOnce && Date.now() - devStartedAt < 90_000),
559
1443
  name: currentMeta.name,
560
1444
  icon: currentMeta.icon,
561
1445
  buildId,
562
1446
  schemaJson: currentMeta.schemaJson,
1447
+ // The expose block rides the same compile as the schema — the dev
1448
+ // visitor preview is gated on it (devExposureJson).
1449
+ exposure: currentMeta.exposure,
563
1450
  }),
564
1451
  signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
565
1452
  });
@@ -570,26 +1457,51 @@ async function dev() {
570
1457
  return false;
571
1458
  }
572
1459
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
1460
+ // A dead key is a SIGNED-OUT session — advertise it so the desktop
1461
+ // (which owns the session) can surface sign-in instead of letting
1462
+ // this line repeat in a log nobody watches.
1463
+ if (data?.code === "INVALID_CLI_KEY" || data?.code === "MISSING_CLI_KEY") {
1464
+ sf.write({ loggedIn: false });
1465
+ }
573
1466
  return false;
574
1467
  }
575
- if (data?.publishRequested && !publishing) {
1468
+ if (!registeredOnce) {
1469
+ registeredOnce = true;
1470
+ sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
1471
+ } else {
1472
+ sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1473
+ }
1474
+ if (data?.publishRequested && !publishing && !ended) {
576
1475
  publishing = true;
1476
+ sf.write({ publishing: true });
577
1477
  console.log("publish: requested from the workspace — building & uploading…");
1478
+ const pubTee = logSink.source();
578
1479
  await new Promise((resolve) => {
579
- const pub = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
1480
+ pubChild = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
580
1481
  cwd: appDir,
581
- stdio: ["ignore", "inherit", "inherit"],
1482
+ stdio: ["ignore", "pipe", "pipe"],
582
1483
  });
583
- pub.on("exit", (code) => {
1484
+ pubChild.stdout.on("data", (c) => {
1485
+ process.stdout.write(c);
1486
+ pubTee(c);
1487
+ });
1488
+ pubChild.stderr.on("data", (c) => {
1489
+ process.stderr.write(c);
1490
+ pubTee(c);
1491
+ });
1492
+ pubChild.on("exit", (code) => {
1493
+ pubChild = null;
1494
+ pubTee.flush();
584
1495
  console.log(
585
1496
  code === 0
586
- ? "publish: done — the workspace now serves the new version (dev session continues)"
1497
+ ? "publish: done — the app is Live for the workspace (Studio session continues)"
587
1498
  : "publish: FAILED — fix the errors above, then click Publish again",
588
1499
  );
589
1500
  resolve(undefined);
590
1501
  });
591
1502
  });
592
1503
  publishing = false;
1504
+ sf.write({ publishing: false });
593
1505
  }
594
1506
  return true;
595
1507
  } catch {
@@ -600,7 +1512,7 @@ async function dev() {
600
1512
 
601
1513
  async function startDevSession() {
602
1514
  if (!cfg?.key) {
603
- console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
1515
+ console.log("dev: not logged in — workspace Studio disabled (run `monty login`)");
604
1516
  return;
605
1517
  }
606
1518
  await clearDevSession();
@@ -611,6 +1523,9 @@ async function dev() {
611
1523
  const version = ++tunnelVersion;
612
1524
  if (!initial) {
613
1525
  console.log(`tunnel: changed to ${url}`);
1526
+ // The old public URL is dead the moment cloudflared rotated — stop
1527
+ // advertising it while DNS gating runs (it can fail for minutes).
1528
+ sf.write({ tunnelUrl: null });
614
1529
  await clearDevSession();
615
1530
  }
616
1531
  console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
@@ -618,13 +1533,14 @@ async function dev() {
618
1533
  if (ended || version !== tunnelVersion) return "superseded";
619
1534
  if (!dnsLive) {
620
1535
  if (initial) {
621
- console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
1536
+ console.log("tunnel: DNS never propagated — Studio registered on localhost (visible on this machine's browser only)");
622
1537
  } else {
623
1538
  console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
624
1539
  }
625
1540
  return "failed";
626
1541
  }
627
1542
  originUrl = url;
1543
+ sf.write({ tunnelUrl: url });
628
1544
  console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
629
1545
  if (!initial && !(await heartbeat(originUrl))) {
630
1546
  console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
@@ -641,36 +1557,60 @@ async function dev() {
641
1557
  };
642
1558
  if (!rest.includes("--no-tunnel")) {
643
1559
  console.log("tunnel: starting (cloudflared quick tunnel)…");
644
- const t = await startTunnel(port, registerTunnelUrl);
1560
+ // cloudflared output is teed to dev.log only (the terminal stays quiet,
1561
+ // exactly as today) — post-mortems get the tunnel noise.
1562
+ const cloudflaredTee = logSink.source("cloudflared: ");
1563
+ const t = await startTunnel(port, registerTunnelUrl, cloudflaredTee);
645
1564
  tunnelChild = t.child;
1565
+ sf.write({ tunnelPid: t.child?.pid ?? null });
646
1566
  if (t.url) {
647
1567
  console.log(`tunnel: ${t.url}`);
648
1568
  await activateTunnelUrl(t.url, { initial: true });
649
1569
  } else {
650
- console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
1570
+ console.log("tunnel: unavailable — Studio registered on localhost (visible on this machine's browser only)");
651
1571
  }
652
1572
  }
653
1573
  const registered = await heartbeat(originUrl, { claim: true });
654
1574
  console.log(
655
1575
  registered
656
- ? `studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`
1576
+ ? `studio: ${host}/studio/${meta.slug} — your app runs there while this is up; click Publish to go Live`
657
1577
  : `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
658
1578
  );
659
1579
  hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
660
1580
  }
661
1581
 
662
1582
  let announced = false;
1583
+ const viteOutTee = logSink.source();
1584
+ const viteErrTee = logSink.source();
663
1585
  child.stdout.on("data", (chunk) => {
664
1586
  const text = chunk.toString();
665
1587
  process.stdout.write(text);
666
- if (!announced && /localhost:\d+/.test(text)) {
1588
+ viteOutTee(chunk);
1589
+ // Strip ANSI before scanning: under FORCE_COLOR/colorized environments
1590
+ // (Solo, some CI ptys) vite colors the URL and the escape codes land
1591
+ // BETWEEN "localhost:" and the digits — the raw text never matches.
1592
+ if (!announced && /localhost:\d+/.test(text.replace(ANSI_RE, ""))) {
667
1593
  announced = true;
668
- console.log(`data: sandboxed to "${meta.slug}#dev" (live records untouched)`);
1594
+ sf.write({ state: "ready" });
1595
+ console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
669
1596
  console.log(`ready: http://localhost:${port}`);
670
1597
  void startDevSession();
671
1598
  }
672
1599
  });
1600
+ // vite stderr is where build errors and the SDK's browser-error mirror
1601
+ // land — piped (was inherit) so `monty logs` sees them too.
1602
+ child.stderr.on("data", (chunk) => {
1603
+ process.stderr.write(chunk);
1604
+ viteErrTee(chunk);
1605
+ });
1606
+ child.on("error", (e) => {
1607
+ void endSession().then(() => {
1608
+ fail("VITE_SPAWN_FAILED", `Could not start vite: ${e?.message ?? e}. Run \`monty install\`, then retry.`);
1609
+ });
1610
+ });
673
1611
  child.on("exit", (code) => {
1612
+ viteOutTee.flush();
1613
+ viteErrTee.flush();
674
1614
  void endSession().then(() => process.exit(code ?? 0));
675
1615
  });
676
1616
  process.on("SIGINT", () => {
@@ -679,9 +1619,132 @@ async function dev() {
679
1619
  process.on("SIGTERM", () => {
680
1620
  void endSession().then(() => process.exit(143));
681
1621
  });
1622
+ // Closing the terminal window (SIGHUP) and unexpected crashes must clean
1623
+ // up too — every stale dev.json is a lie to the next `monty dev`.
1624
+ process.on("SIGHUP", () => {
1625
+ void endSession().then(() => process.exit(129));
1626
+ });
1627
+ process.on("uncaughtException", (e) => {
1628
+ console.error(`dev: unexpected error — ${e?.stack ?? e}`);
1629
+ void endSession().then(() => process.exit(1));
1630
+ });
1631
+ process.on("unhandledRejection", (e) => {
1632
+ console.error(`dev: unexpected error — ${e?.stack ?? e}`);
1633
+ void endSession().then(() => process.exit(1));
1634
+ });
682
1635
  }
683
1636
 
684
1637
 
1638
+ // ── monty logs ─────────────────────────────────────────────────────────────
1639
+ // The agent's window into the (possibly background) dev shell: pure file
1640
+ // reads over .monty/dev.log — no skills refresh, no sdk, no compile, no
1641
+ // network. stdout carries ONLY log lines (every note goes to stderr), so
1642
+ // `monty logs | grep …` stays clean.
1643
+ async function logs() {
1644
+ const appDir = requireAppDir("logs");
1645
+ const { log, prevLog } = devPaths(appDir);
1646
+ const follow = rest.includes("-f") || rest.includes("--follow");
1647
+ const nIdx = rest.indexOf("-n");
1648
+ const n = nIdx >= 0 ? Number(rest[nIdx + 1]) : LOGS_DEFAULT_LINES;
1649
+ if (!Number.isInteger(n) || n < 0 || n > 10000) {
1650
+ fail("LOGS_USAGE", "Usage: monty logs [-n <lines>] [-f] — <lines> is a non-negative integer (default 50).");
1651
+ }
1652
+ const note = (m) => process.stderr.write(`${m}\n`);
1653
+
1654
+ const s = await readDevJson(appDir);
1655
+ const sessionLive =
1656
+ s !== null &&
1657
+ !s.unreadable &&
1658
+ Number.isInteger(s.pid) &&
1659
+ pidAlive(s.pid) &&
1660
+ typeof s.updatedAt === "number" &&
1661
+ Date.now() - s.updatedAt <= DEV_JSON_STALE_MS;
1662
+
1663
+ if (!existsSync(log)) {
1664
+ if (sessionLive && follow) {
1665
+ note("note: dev session starting — waiting for the log file…");
1666
+ } else if (sessionLive) {
1667
+ note("note: dev session starting — no log yet (`monty logs -f` waits for it)");
1668
+ return;
1669
+ } else if (existsSync(prevLog)) {
1670
+ note("note: no dev session is running — the previous session's log is .monty/dev.log.1");
1671
+ return;
1672
+ } else {
1673
+ fail("NO_DEV_LOG", "No dev session has run in this app folder yet. Start one with `monty dev`.");
1674
+ }
1675
+ } else if (!sessionLive && !(await checkDevSession(appDir)).live) {
1676
+ // The cheap pid+fresh check false-negatives right after a laptop wake —
1677
+ // only print the note once the full liveness check (port + vite probe)
1678
+ // agrees the session is gone.
1679
+ note("note: no dev session is running — showing the last session's log (start one with `monty dev`)");
1680
+ }
1681
+
1682
+ let offset = 0;
1683
+ let lastIno = null;
1684
+ if (existsSync(log)) {
1685
+ const content = readFileSync(log, "utf8");
1686
+ offset = Buffer.byteLength(content);
1687
+ try {
1688
+ lastIno = statSync(log).ino;
1689
+ } catch {
1690
+ /* raced a rotation — the poll loop resyncs */
1691
+ }
1692
+ if (n > 0) {
1693
+ const lines = content.split("\n");
1694
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
1695
+ for (const l of lines.slice(-n)) process.stdout.write(`${l}\n`);
1696
+ }
1697
+ }
1698
+ if (!follow) return;
1699
+
1700
+ // Follow by polling the PATH (never a held fd): a rotation shrinks the
1701
+ // file (reopen at 0 — the fresh file starts with a marker, nothing
1702
+ // replays), a restart repopulates the same path, transient ENOENT is the
1703
+ // rename window. fs.watch is deliberately not used (platform-flaky,
1704
+ // inode-bound across rotation).
1705
+ let partial = "";
1706
+ setInterval(() => {
1707
+ let st;
1708
+ try {
1709
+ st = statSync(log);
1710
+ } catch {
1711
+ return;
1712
+ }
1713
+ // A new inode at the same path = rotation or session restart — reopen at
1714
+ // 0 even when the fresh file already grew past our old offset.
1715
+ if (lastIno !== null && st.ino !== lastIno) {
1716
+ offset = 0;
1717
+ partial = "";
1718
+ }
1719
+ lastIno = st.ino;
1720
+ const size = st.size;
1721
+ if (size < offset) {
1722
+ offset = 0;
1723
+ partial = "";
1724
+ }
1725
+ if (size === offset) return;
1726
+ let fd;
1727
+ try {
1728
+ fd = openSync(log, "r");
1729
+ } catch {
1730
+ return;
1731
+ }
1732
+ try {
1733
+ const buf = Buffer.alloc(size - offset);
1734
+ const read = readSync(fd, buf, 0, buf.length, offset);
1735
+ offset += read;
1736
+ const text = partial + buf.toString("utf8", 0, read);
1737
+ const lines = text.split("\n");
1738
+ partial = lines.pop();
1739
+ for (const l of lines) process.stdout.write(`${l}\n`);
1740
+ } finally {
1741
+ closeSync(fd);
1742
+ }
1743
+ }, LOGS_POLL_MS);
1744
+ process.on("SIGINT", () => process.exit(0));
1745
+ process.on("SIGTERM", () => process.exit(0));
1746
+ }
1747
+
685
1748
  // trycloudflare DNS takes up to a couple of minutes to propagate. Registering
686
1749
  // the origin before it resolves would make admins' browsers cache NXDOMAIN
687
1750
  // (macOS negative cache ≈ 30 min of a broken iframe) — so gate on DNS via
@@ -719,13 +1782,15 @@ async function waitForDns(hostname) {
719
1782
 
720
1783
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
721
1784
  // binary on first use). Resolves with the public URL, or null on failure —
722
- // dev mode then falls back to localhost-only registration.
723
- function startTunnel(port, onUrlChange) {
1785
+ // Studio then falls back to localhost-only registration. onOutput receives
1786
+ // every chunk (both fds) for the dev.log tee.
1787
+ function startTunnel(port, onUrlChange, onOutput) {
724
1788
  return new Promise((resolve) => {
725
1789
  let child;
726
1790
  try {
727
1791
  child = spawn("npx", ["-y", "cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
728
1792
  stdio: ["ignore", "pipe", "pipe"],
1793
+ shell: process.platform === "win32", // Node >=22 refuses .cmd spawns without it
729
1794
  });
730
1795
  } catch {
731
1796
  return resolve({ child: null, url: null });
@@ -739,7 +1804,9 @@ function startTunnel(port, onUrlChange) {
739
1804
  }, 45_000);
740
1805
  let currentUrl = null;
741
1806
  const scan = (chunk) => {
742
- const urls = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
1807
+ onOutput?.(chunk);
1808
+ // Same ANSI hazard as the vite ready-scan: match on stripped text.
1809
+ const urls = String(chunk).replace(ANSI_RE, "").match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
743
1810
  for (const url of urls) {
744
1811
  if (url === currentUrl) continue;
745
1812
  currentUrl = url;
@@ -862,12 +1929,56 @@ async function docs() {
862
1929
  `Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
863
1930
  }
864
1931
 
1932
+ // ── monty secret ─────────────────────────────────────────────────────────
1933
+ // `monty secret set KEY [value]` / `monty secret rm KEY` — per-app
1934
+ // server-function secrets. The value goes to Cloudflare's per-script secrets
1935
+ // layer for THIS app's fn-worker via the host; never stored by Monty, never
1936
+ // readable back. Read from the arg, then a TTY prompt, then stdin (piping).
1937
+ async function secret() {
1938
+ const appDir = requireAppDir("secret");
1939
+ const meta = await compileConfig(appDir);
1940
+ const config = loadConfig();
1941
+ if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
1942
+ const [sub, name] = rest.filter((a) => !a.startsWith("-"));
1943
+ if ((sub !== "set" && sub !== "rm") || !name) {
1944
+ fail("SECRET_USAGE", "Usage: `monty secret set KEY [value]` (omit value to be prompted / piped) or `monty secret rm KEY`.");
1945
+ }
1946
+ if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(name)) {
1947
+ fail("BAD_SECRET_NAME", "Secret names are UPPER_SNAKE_CASE (A-Z, 0-9, _), starting with a letter — e.g. OPENAI_API_KEY.");
1948
+ }
1949
+ const del = sub === "rm";
1950
+ let value;
1951
+ if (!del) {
1952
+ value = rest.filter((a) => !a.startsWith("-"))[2];
1953
+ if (value === undefined) {
1954
+ if (process.stdin.isTTY) {
1955
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1956
+ value = (await rl.question(`value for ${name}: `)).trim();
1957
+ rl.close();
1958
+ } else {
1959
+ value = readFileSync(0, "utf8").trim(); // piped
1960
+ }
1961
+ }
1962
+ if (!value) fail("EMPTY_SECRET", "No value provided.");
1963
+ }
1964
+ const res = await fetch(`${config.host}/api/secret`, {
1965
+ method: "POST",
1966
+ headers: { authorization: `Bearer ${config.key}`, "content-type": "application/json" },
1967
+ body: JSON.stringify({ slug: meta.slug, name, ...(del ? { delete: true } : { value }) }),
1968
+ });
1969
+ const body = await res.json().catch(() => null);
1970
+ if (!res.ok || !body?.ok) {
1971
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not change the secret.");
1972
+ }
1973
+ console.log(del ? `secret: removed ${name} from ${meta.slug}` : `secret: set ${name} on ${meta.slug} (write-only; not readable back)`);
1974
+ }
1975
+
865
1976
  // ── monty deploy ───────────────────────────────────────────────────────────
866
1977
  async function deploy() {
867
1978
  const appDir = requireAppDir("deploy");
868
1979
  ensureSdk(appDir);
869
1980
  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.");
1981
+ 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
1982
  }
872
1983
  const config = loadConfig();
873
1984
  if (!config?.key) {
@@ -895,6 +2006,58 @@ async function deploy() {
895
2006
  meta.buildId = readFileSync(buildFile, "utf8").trim();
896
2007
  }
897
2008
  const form = new FormData();
2009
+ // 3a) Server functions (optional): bundle server/index.ts into one worker
2010
+ // script and ride the SAME deploy. The manifest (fns) goes in meta so
2011
+ // the router gates /__monty/fn/* without a lookup.
2012
+ const serverBundle = await bundleServerFns(appDir, meta.schedule);
2013
+ const publicFns = Array.isArray(meta.publicFns) ? meta.publicFns : [];
2014
+ const scheduleEntries = Object.entries(meta.schedule ?? {});
2015
+ if (!serverBundle && (publicFns.length > 0 || scheduleEntries.length > 0)) {
2016
+ fail("SERVER_DIR_MISSING",
2017
+ "monty.config.ts declares publicFns/schedule, but this app has no server/index.ts. Create it with the named exports, or remove the declarations.");
2018
+ }
2019
+ if (serverBundle) {
2020
+ for (const name of publicFns) {
2021
+ if (!serverBundle.fns.includes(name)) {
2022
+ fail("PUBLIC_FN_UNKNOWN",
2023
+ `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.`);
2024
+ }
2025
+ }
2026
+ for (const [name] of scheduleEntries) {
2027
+ if (!serverBundle.fns.includes(name)) {
2028
+ fail("SCHEDULE_UNKNOWN_FN",
2029
+ `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.`);
2030
+ }
2031
+ }
2032
+ meta.fns = serverBundle.fns;
2033
+ form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
2034
+ console.log(`fns: bundled ${serverBundle.fns.length} server function(s) (${serverBundle.fns.join(", ")})`);
2035
+ if (publicFns.length > 0) {
2036
+ console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
2037
+ }
2038
+ if (scheduleEntries.length > 0) {
2039
+ console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
2040
+ }
2041
+ }
2042
+ // 3b) SOURCE snapshot rides every publish. Without it the platform keeps
2043
+ // only the minified bundle and the sole copy of the app's code is this
2044
+ // folder — delete it and the source is gone forever. The snapshot is what
2045
+ // `monty pull <slug>` restores on any machine, and the publish lands in
2046
+ // the same version history as `monty commit`.
2047
+ let sourceHash = null;
2048
+ {
2049
+ const packed = packSource(appDir);
2050
+ if (packed === null) {
2051
+ console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this publish.");
2052
+ } else if (packed.tooLarge) {
2053
+ console.log("source: WARNING — snapshot exceeds 10 MB, skipped; `monty pull` will not work for this app. Remove large assets from the app folder.");
2054
+ } else {
2055
+ sourceHash = packed.hash;
2056
+ meta.sourceHash = sourceHash;
2057
+ form.set("source", new Blob([packed.buf]), "source.tar.gz");
2058
+ console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this publish (restore anywhere: monty pull ${meta.slug})`);
2059
+ }
2060
+ }
898
2061
  form.set("monty", JSON.stringify(meta));
899
2062
  let total = 0;
900
2063
  for (const file of files) {
@@ -915,51 +2078,108 @@ async function deploy() {
915
2078
  }
916
2079
  console.log(`origin: ${body.origin}`);
917
2080
  console.log(`deployed: ${body.url} (version ${body.version})`);
2081
+ // Stamp what was published — pull uses this to tell "unchanged since last
2082
+ // sync" from "locally modified".
2083
+ if (sourceHash) {
2084
+ writeFileSync(
2085
+ join(appDir, ".monty", "source.json"),
2086
+ JSON.stringify({ hash: sourceHash, syncedAt: Date.now() }) + "\n",
2087
+ );
2088
+ }
918
2089
  }
919
2090
 
920
- async function compileConfig(appDir, { soft = false } = {}) {
2091
+ // Bundle server/index.ts (if present) into ONE Worker script: a generated
2092
+ // entry wraps the app's exports with @montytools/sdk/fn-worker's makeFnWorker,
2093
+ // esbuild bundles it for workerd. node: imports are rejected at compile time
2094
+ // — Live runs on Cloudflare Workers, not Node. Returns { code, fns } or null.
2095
+ async function bundleServerFns(appDir, schedule) {
2096
+ const serverEntry = join(appDir, "server", "index.ts");
2097
+ if (!existsSync(serverEntry)) return null;
921
2098
  const { build } = await import("esbuild");
922
2099
  const tmpDir = join(appDir, ".monty");
923
2100
  mkdirSync(tmpDir, { recursive: true });
924
- const entry = join(tmpDir, "compile-entry.mjs");
925
- const out = join(tmpDir, "compile-out.mjs");
2101
+ const entry = join(tmpDir, "fn-worker-entry.mjs");
2102
+ const out = join(tmpDir, "fn-worker-out.mjs");
2103
+ // The schedule map is baked into the bundle: Cloudflare's scheduled()
2104
+ // hands back only the matching cron expression, so the worker needs the
2105
+ // expression→fn mapping at runtime.
926
2106
  writeFileSync(entry, [
927
- `import { app } from "../monty.config";`,
928
- `import { compileApp } from "@montytools/sdk/compile";`,
929
- `process.stdout.write(JSON.stringify(compileApp(app)));`,
2107
+ `import * as appFns from "../server/index";`,
2108
+ `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
2109
+ `export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
930
2110
  ].join("\n"));
2111
+ // Fail the deploy if server code reaches for Node built-ins — a Worker
2112
+ // can't run them, and a silent runtime crash on Live is the worst outcome.
2113
+ const banPlatformImports = {
2114
+ name: "ban-platform-imports",
2115
+ setup(b) {
2116
+ b.onResolve({ filter: /^node:/ }, (a) => ({
2117
+ errors: [{ text: `server/ cannot import "${a.path}" — server functions run on Cloudflare Workers (Web APIs only: fetch, crypto, URL…), not Node.` }],
2118
+ }));
2119
+ b.onResolve({ filter: /^cloudflare:/ }, (a) => ({
2120
+ errors: [{ text: `server/ cannot import "${a.path}" — Cloudflare bindings are platform-private. Use ctx.records, ctx.secrets, fetch, crypto, and other Web APIs.` }],
2121
+ }));
2122
+ },
2123
+ };
2124
+ let fns;
931
2125
  try {
932
2126
  await build({
933
2127
  entryPoints: [entry],
934
2128
  outfile: out,
935
2129
  bundle: true,
936
- platform: "node",
937
2130
  format: "esm",
938
- target: "node22",
2131
+ platform: "browser",
2132
+ target: "es2022",
2133
+ conditions: ["workerd", "worker", "browser"],
939
2134
  absWorkingDir: appDir,
940
2135
  logLevel: "silent",
2136
+ plugins: [banPlatformImports],
941
2137
  });
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).`);
2138
+ fns = discoverFnExports(serverEntry);
2139
+ if (fns.length === 0) {
2140
+ 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
2141
  }
950
- return JSON.parse(result.stdout);
2142
+ return { code: readFileSync(out, "utf8"), fns };
951
2143
  } catch (e) {
952
- if (e?.errors) {
2144
+ if (e?.code === "NO_FN_EXPORTS") throw e; // fail() already exited; guard for safety
2145
+ const msg = e?.errors?.[0]?.text ?? e?.message ?? String(e);
2146
+ fail("FN_BUNDLE_FAILED", `Could not bundle server/index.ts: ${msg}`);
2147
+ } finally {
2148
+ rmSync(entry, { force: true });
2149
+ rmSync(out, { force: true });
2150
+ }
2151
+ }
2152
+
2153
+ // The manifest = the named exports of server/index.ts, read statically
2154
+ // (regex over `export … function NAME` / `export const NAME =`). onEvent is
2155
+ // included when present but the platform invokes it, not useServerFn.
2156
+ function discoverFnExports(serverEntry) {
2157
+ const src = readFileSync(serverEntry, "utf8");
2158
+ const names = new Set();
2159
+ const re =
2160
+ /export\s+(?:async\s+)?function\s+([a-zA-Z_$][\w$]*)|export\s+const\s+([a-zA-Z_$][\w$]*)\s*=/g;
2161
+ let m;
2162
+ while ((m = re.exec(src))) {
2163
+ const name = m[1] ?? m[2];
2164
+ if (/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name)) names.add(name);
2165
+ }
2166
+ return [...names];
2167
+ }
2168
+
2169
+ // Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
2170
+ // Studio heartbeat's last good schema through transient config breakage.
2171
+ async function compileConfig(appDir, { soft = false } = {}) {
2172
+ try {
2173
+ return await compileAppConfig(appDir);
2174
+ } catch (e) {
2175
+ if (e instanceof CompileError) {
953
2176
  if (soft) {
954
2177
  console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
955
2178
  return null;
956
2179
  }
957
- fail("CONFIG_COMPILE_FAILED", `esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}`);
2180
+ fail(e.code, e.fix);
958
2181
  }
959
2182
  throw e;
960
- } finally {
961
- rmSync(entry, { force: true });
962
- rmSync(out, { force: true });
963
2183
  }
964
2184
  }
965
2185
 
@@ -984,9 +2204,70 @@ function walk(dir) {
984
2204
  return out;
985
2205
  }
986
2206
 
2207
+ // ── cron matching (the Studio ticker in `monty dev`) ──────────────────────
2208
+ // UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
2209
+ // names (JAN, MON). Deliberately forgiving: an unparsable field simply never
2210
+ // matches locally — Cloudflare is the syntax authority at deploy, so a bad
2211
+ // expression fails there with its own message.
2212
+ 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 };
2213
+ const CRON_DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
2214
+
2215
+ function cronMatches(expr, date) {
2216
+ const fields = String(expr).trim().split(/\s+/);
2217
+ if (fields.length !== 5) return false;
2218
+ const values = [
2219
+ date.getUTCMinutes(),
2220
+ date.getUTCHours(),
2221
+ date.getUTCDate(),
2222
+ date.getUTCMonth() + 1,
2223
+ date.getUTCDay(),
2224
+ ];
2225
+ const bounds = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
2226
+ return fields.every((field, i) => cronFieldMatches(field, values[i], bounds[i], i));
2227
+ }
2228
+
2229
+ function cronFieldMatches(field, value, [lo, hi], idx) {
2230
+ const names = idx === 3 ? CRON_MONTHS : idx === 4 ? CRON_DAYS : null;
2231
+ const num = (t) => {
2232
+ const named = names?.[t.toLowerCase()];
2233
+ if (named !== undefined) return named;
2234
+ const n = Number(t);
2235
+ return Number.isInteger(n) ? n : null;
2236
+ };
2237
+ for (const part of field.split(",")) {
2238
+ const [rangeRaw, stepRaw] = part.split("/");
2239
+ const step = stepRaw === undefined ? 1 : Number(stepRaw);
2240
+ if (!Number.isInteger(step) || step < 1) continue;
2241
+ let from;
2242
+ let to;
2243
+ if (rangeRaw === "*" || rangeRaw === "") {
2244
+ from = lo;
2245
+ to = hi;
2246
+ } else if (rangeRaw.includes("-")) {
2247
+ const [a, b] = rangeRaw.split("-");
2248
+ from = num(a);
2249
+ to = num(b);
2250
+ } else {
2251
+ from = num(rangeRaw);
2252
+ to = stepRaw === undefined ? from : hi; // "5/10": from 5 to max, step 10
2253
+ }
2254
+ if (from === null || to === null || from > to) continue;
2255
+ for (let v = from; v <= to; v += step) {
2256
+ // day-of-week: cron accepts 7 for Sunday alongside 0
2257
+ if (v === value || (idx === 4 && v === 7 && value === 0)) return true;
2258
+ }
2259
+ }
2260
+ return false;
2261
+ }
2262
+
987
2263
  // ── dispatch ───────────────────────────────────────────────────────────────
988
2264
  // Keep agent skills fresh on every invocation (user level + current app).
989
- installSkills({ appDir: findAppRoot(process.cwd()) });
2265
+ // `dev` and `logs` skip the refresh here: attach and log reads must stay
2266
+ // fast (a skills refresh can shell out to npx for two minutes) — dev's
2267
+ // fresh-start path installs skills itself once it owns the session.
2268
+ if (command !== "dev" && command !== "logs") {
2269
+ installSkills({ appDir: findAppRoot(process.cwd()) });
2270
+ }
990
2271
 
991
2272
  switch (command) {
992
2273
  case "login":
@@ -995,9 +2276,22 @@ switch (command) {
995
2276
  case "create":
996
2277
  await create();
997
2278
  break;
2279
+ case "pull":
2280
+ await pull();
2281
+ break;
2282
+ case "commit":
2283
+ await commit();
2284
+ break;
2285
+ case "log":
2286
+ case "versions":
2287
+ await versionsLog();
2288
+ break;
998
2289
  case "dev":
999
2290
  await dev();
1000
2291
  break;
2292
+ case "logs":
2293
+ await logs();
2294
+ break;
1001
2295
  case "add":
1002
2296
  await add();
1003
2297
  break;
@@ -1033,21 +2327,28 @@ switch (command) {
1033
2327
  case "deploy":
1034
2328
  await deploy();
1035
2329
  break;
2330
+ case "secret":
2331
+ await secret();
2332
+ break;
1036
2333
  default:
1037
- console.log("usage: monty <login|create|current|select|apps|install|dev|build|typecheck|add|components|docs|deploy|skills>");
2334
+ console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|skills>");
1038
2335
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
1039
- 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)");
2336
+ console.log(" create <slug> [--name N] [--icon I] [--build ID] register + stamp a new app into ~/.monty/apps/<id> (needs login)");
2337
+ console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
2338
+ console.log(" commit [-m \"message\"] version the app's source in the workspace without publishing");
2339
+ console.log(" log [slug] the app's source version history (commits + publishes)");
2340
+ console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app in Studio, or attach to a running session (sandboxed data)");
2341
+ console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, publish results)");
1041
2342
  console.log(" add <name...> install curated UI components (see `monty components`)");
1042
2343
  console.log(" components [query] list the curated component catalog");
1043
2344
  console.log(" docs <name> view a component's source before installing");
1044
2345
  console.log(" current which app folder am I in?");
1045
2346
  console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
1046
- console.log(" apps list local apps in ~/Monty");
2347
+ console.log(" apps list local apps (~/.monty/apps + legacy ~/Monty)");
1047
2348
  console.log(" install install app dependencies");
1048
2349
  console.log(" build production build (vite, via monty)");
1049
2350
  console.log(" typecheck typecheck (builds first if needed)");
1050
- console.log(" deploy build + upload this app");
2351
+ console.log(" deploy build + upload this app straight to Live");
1051
2352
  console.log(" skills install/refresh the agent build skill");
1052
2353
  process.exit(command ? 1 : 0);
1053
2354
  }