@montytools/cli 0.2.10 → 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";
@@ -20,9 +22,29 @@ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
20
22
  const DEFAULT_HOST = "https://usemonty.dev";
21
23
  const DEV_SESSION_HEARTBEAT_MS = 30_000;
22
24
  const DEV_SESSION_REQUEST_TIMEOUT_MS = 10_000;
23
- // Every app's source lives in one predictable place. `monty create` stamps
24
- // here by default (override with --dir) and `monty login` provisions it.
25
- 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");
26
48
 
27
49
  const [, , command, ...rest] = process.argv;
28
50
 
@@ -297,12 +319,24 @@ function readSlugFromConfig(dir) {
297
319
  }
298
320
  }
299
321
 
300
- function listLocalApps() {
301
- if (!existsSync(MONTY_HOME)) return [];
302
- return readdirSync(MONTY_HOME)
303
- .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))
304
334
  .filter((p) => existsSync(join(p, "monty.config.ts")))
305
- .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)];
306
340
  }
307
341
 
308
342
  function current() {
@@ -311,8 +345,10 @@ function current() {
311
345
  fail("NOT_IN_APP", `You are not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one.`);
312
346
  }
313
347
  console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
348
+ const id = readIdFromConfig(root);
349
+ if (id) console.log(`id: ${id}`);
314
350
  console.log(`path: ${root}`);
315
- if (!root.startsWith(MONTY_HOME)) {
351
+ if (!root.startsWith(MONTY_HOME) && !root.startsWith(LEGACY_MONTY_HOME)) {
316
352
  console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
317
353
  }
318
354
  }
@@ -323,10 +359,10 @@ function select() {
323
359
  fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
324
360
  }
325
361
  const apps = listLocalApps();
326
- 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);
327
363
  if (!hit) {
328
364
  const known = apps.map((a) => a.slug).join(", ") || "(none)";
329
- 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}\`.`);
330
366
  }
331
367
  // Bare path on stdout so command substitution works: cd "$(monty select x)"
332
368
  console.log(hit.path);
@@ -341,22 +377,267 @@ function apps() {
341
377
  for (const a of local) console.log(`${a.slug}\t${a.path}`);
342
378
  }
343
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
+
344
587
  // ── monty create ───────────────────────────────────────────────────────────
345
588
  async function create() {
346
589
  const slug = rest.find((a) => !a.startsWith("--"));
347
- if (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
348
- 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.`);
349
599
  }
350
600
  const name =
351
601
  flag("name") ??
352
602
  slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
353
603
  const icon = flag("icon") ?? "layout-grid";
354
- // 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.
355
636
  const target = flag("dir")
356
637
  ? join(process.cwd(), flag("dir"))
357
- : join(MONTY_HOME, slug);
638
+ : join(MONTY_HOME, appId);
358
639
  if (existsSync(target)) {
359
- 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.`);
360
641
  }
361
642
  mkdirSync(dirname(target), { recursive: true });
362
643
 
@@ -380,12 +661,13 @@ async function create() {
380
661
  },
381
662
  });
382
663
 
383
- // 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).
384
666
  const configPath = join(target, "monty.config.ts");
385
667
  writeFileSync(
386
668
  configPath,
387
669
  readFileSync(configPath, "utf8")
388
- .replace(/slug: "[^"]*"/, `slug: "${slug}"`)
670
+ .replace(/^([ \t]*)slug: "[^"]*"/m, `$1id: "${appId}",\n$1slug: "${slug}"`)
389
671
  .replace(/name: "[^"]*"/, `name: "${name}"`)
390
672
  .replace(/icon: "[^"]*"/, `icon: "${icon}"`),
391
673
  );
@@ -399,8 +681,27 @@ async function create() {
399
681
  readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
400
682
  );
401
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
+
402
704
  // Client config comes from the platform — public values, no dashboard trip.
403
- const host = loadConfig()?.host ?? DEFAULT_HOST;
404
705
  try {
405
706
  const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
406
707
  writeFileSync(
@@ -418,18 +719,15 @@ async function create() {
418
719
  if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
419
720
  mkdirSync(join(target, ".monty"), { recursive: true });
420
721
  writeFileSync(join(target, ".monty", "build"), buildId + "\n");
421
- const cfg = loadConfig();
422
- if (cfg?.key) {
423
- try {
424
- await fetch(`${cfg.host ?? DEFAULT_HOST}/api/build`, {
425
- method: "POST",
426
- headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
427
- body: JSON.stringify({ buildId, slug }),
428
- });
429
- console.log("build: workspace notifiedthe New app screen is following along");
430
- } catch {
431
- /* progress signal only — never block create */
432
- }
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 */
433
731
  }
434
732
  }
435
733
 
@@ -556,6 +854,353 @@ function syncSdkViteCache(appDir) {
556
854
  writeFileSync(stampPath, `${v}\n`);
557
855
  }
558
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`);
1202
+ }
1203
+
559
1204
  // ── monty dev ──────────────────────────────────────────────────────────────
560
1205
  // Puts the app in Studio: vite locally + a Cloudflare quick tunnel registered
561
1206
  // as the app's STUDIO channel, so workspace admins see the app (HMR included)
@@ -564,6 +1209,51 @@ function syncSdkViteCache(appDir) {
564
1209
  // clicks Publish in the workspace, this process builds + uploads to Live.
565
1210
  async function dev() {
566
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 });
567
1257
  ensureSdk(appDir);
568
1258
  const meta = await compileConfig(appDir);
569
1259
  const cfg = loadConfig();
@@ -573,16 +1263,26 @@ async function dev() {
573
1263
  const requested = flag("port");
574
1264
  const port = requested ? Number(requested) : await freePort(5173);
575
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
+
576
1271
  console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
577
- const child = spawn("npx", ["vite", "dev", "--port", String(port), "--strictPort"], {
1272
+ const child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
578
1273
  cwd: appDir,
579
- stdio: ["ignore", "pipe", "inherit"],
1274
+ stdio: ["ignore", "pipe", "pipe"],
580
1275
  });
581
1276
 
582
1277
  let tunnelChild = null;
1278
+ let pubChild = null;
583
1279
  let hbTimer = null;
1280
+ let touchTimer = null;
1281
+ let cronTimer = null;
584
1282
  let publishing = false;
585
1283
  let ended = false;
1284
+ let registeredOnce = false;
1285
+ const devStartedAt = Date.now();
586
1286
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
587
1287
  const buildFile = join(appDir, ".monty", "build");
588
1288
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
@@ -593,6 +1293,72 @@ async function dev() {
593
1293
  const configPath = join(appDir, "monty.config.ts");
594
1294
  let configMtime = statSync(configPath).mtimeMs;
595
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
+
596
1362
  async function refreshSchemaIfChanged() {
597
1363
  try {
598
1364
  const m = statSync(configPath).mtimeMs;
@@ -625,7 +1391,15 @@ async function dev() {
625
1391
  if (ended) return;
626
1392
  ended = true;
627
1393
  if (hbTimer) clearInterval(hbTimer);
1394
+ if (touchTimer) clearInterval(touchTimer);
1395
+ if (cronTimer) clearInterval(cronTimer);
1396
+ try { pubChild?.kill(); } catch { /* already gone */ }
628
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();
629
1403
  await clearDevSession();
630
1404
  }
631
1405
 
@@ -633,23 +1407,39 @@ async function dev() {
633
1407
  if (ended) return;
634
1408
  ended = true;
635
1409
  if (hbTimer) clearInterval(hbTimer);
1410
+ if (touchTimer) clearInterval(touchTimer);
1411
+ if (cronTimer) clearInterval(cronTimer);
1412
+ try { pubChild?.kill(); } catch { /* already gone */ }
636
1413
  try { tunnelChild?.kill(); } catch { /* already gone */ }
637
1414
  try { child.kill(); } catch { /* already gone */ }
638
1415
  console.log(`dev-session: superseded — ${fix}`);
1416
+ sf.remove(); // guarded — never deletes the new owner's file
1417
+ logSink.close();
639
1418
  setTimeout(() => process.exit(0), 50);
640
1419
  }
641
1420
 
642
1421
  async function heartbeat(originUrl, { claim = false } = {}) {
1422
+ if (ended) return false; // shutdown already ran — no side effects
643
1423
  await refreshSchemaIfChanged();
644
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;
645
1429
  const r = await fetch(`${host}/api/dev-session`, {
646
1430
  method: "POST",
647
- headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
1431
+ headers: { authorization: `Bearer ${liveKey}`, "content-type": "application/json" },
648
1432
  body: JSON.stringify({
649
1433
  slug: meta.slug,
650
1434
  tunnelUrl: originUrl,
651
1435
  sessionId,
652
- 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),
653
1443
  name: currentMeta.name,
654
1444
  icon: currentMeta.icon,
655
1445
  buildId,
@@ -667,17 +1457,41 @@ async function dev() {
667
1457
  return false;
668
1458
  }
669
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
+ }
670
1466
  return false;
671
1467
  }
672
- 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) {
673
1475
  publishing = true;
1476
+ sf.write({ publishing: true });
674
1477
  console.log("publish: requested from the workspace — building & uploading…");
1478
+ const pubTee = logSink.source();
675
1479
  await new Promise((resolve) => {
676
- const pub = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
1480
+ pubChild = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
677
1481
  cwd: appDir,
678
- stdio: ["ignore", "inherit", "inherit"],
1482
+ stdio: ["ignore", "pipe", "pipe"],
1483
+ });
1484
+ pubChild.stdout.on("data", (c) => {
1485
+ process.stdout.write(c);
1486
+ pubTee(c);
679
1487
  });
680
- pub.on("exit", (code) => {
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();
681
1495
  console.log(
682
1496
  code === 0
683
1497
  ? "publish: done — the app is Live for the workspace (Studio session continues)"
@@ -687,6 +1501,7 @@ async function dev() {
687
1501
  });
688
1502
  });
689
1503
  publishing = false;
1504
+ sf.write({ publishing: false });
690
1505
  }
691
1506
  return true;
692
1507
  } catch {
@@ -708,6 +1523,9 @@ async function dev() {
708
1523
  const version = ++tunnelVersion;
709
1524
  if (!initial) {
710
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 });
711
1529
  await clearDevSession();
712
1530
  }
713
1531
  console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
@@ -722,6 +1540,7 @@ async function dev() {
722
1540
  return "failed";
723
1541
  }
724
1542
  originUrl = url;
1543
+ sf.write({ tunnelUrl: url });
725
1544
  console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
726
1545
  if (!initial && !(await heartbeat(originUrl))) {
727
1546
  console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
@@ -738,8 +1557,12 @@ async function dev() {
738
1557
  };
739
1558
  if (!rest.includes("--no-tunnel")) {
740
1559
  console.log("tunnel: starting (cloudflared quick tunnel)…");
741
- 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);
742
1564
  tunnelChild = t.child;
1565
+ sf.write({ tunnelPid: t.child?.pid ?? null });
743
1566
  if (t.url) {
744
1567
  console.log(`tunnel: ${t.url}`);
745
1568
  await activateTunnelUrl(t.url, { initial: true });
@@ -757,17 +1580,37 @@ async function dev() {
757
1580
  }
758
1581
 
759
1582
  let announced = false;
1583
+ const viteOutTee = logSink.source();
1584
+ const viteErrTee = logSink.source();
760
1585
  child.stdout.on("data", (chunk) => {
761
1586
  const text = chunk.toString();
762
1587
  process.stdout.write(text);
763
- 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, ""))) {
764
1593
  announced = true;
1594
+ sf.write({ state: "ready" });
765
1595
  console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
766
1596
  console.log(`ready: http://localhost:${port}`);
767
1597
  void startDevSession();
768
1598
  }
769
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
+ });
770
1611
  child.on("exit", (code) => {
1612
+ viteOutTee.flush();
1613
+ viteErrTee.flush();
771
1614
  void endSession().then(() => process.exit(code ?? 0));
772
1615
  });
773
1616
  process.on("SIGINT", () => {
@@ -776,9 +1619,132 @@ async function dev() {
776
1619
  process.on("SIGTERM", () => {
777
1620
  void endSession().then(() => process.exit(143));
778
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
+ });
779
1635
  }
780
1636
 
781
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
+
782
1748
  // trycloudflare DNS takes up to a couple of minutes to propagate. Registering
783
1749
  // the origin before it resolves would make admins' browsers cache NXDOMAIN
784
1750
  // (macOS negative cache ≈ 30 min of a broken iframe) — so gate on DNS via
@@ -816,13 +1782,15 @@ async function waitForDns(hostname) {
816
1782
 
817
1783
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
818
1784
  // binary on first use). Resolves with the public URL, or null on failure —
819
- // Studio then falls back to localhost-only registration.
820
- 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) {
821
1788
  return new Promise((resolve) => {
822
1789
  let child;
823
1790
  try {
824
1791
  child = spawn("npx", ["-y", "cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
825
1792
  stdio: ["ignore", "pipe", "pipe"],
1793
+ shell: process.platform === "win32", // Node >=22 refuses .cmd spawns without it
826
1794
  });
827
1795
  } catch {
828
1796
  return resolve({ child: null, url: null });
@@ -836,7 +1804,9 @@ function startTunnel(port, onUrlChange) {
836
1804
  }, 45_000);
837
1805
  let currentUrl = null;
838
1806
  const scan = (chunk) => {
839
- 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) ?? [];
840
1810
  for (const url of urls) {
841
1811
  if (url === currentUrl) continue;
842
1812
  currentUrl = url;
@@ -1039,11 +2009,54 @@ async function deploy() {
1039
2009
  // 3a) Server functions (optional): bundle server/index.ts into one worker
1040
2010
  // script and ride the SAME deploy. The manifest (fns) goes in meta so
1041
2011
  // the router gates /__monty/fn/* without a lookup.
1042
- const serverBundle = await bundleServerFns(appDir);
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
+ }
1043
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
+ }
1044
2032
  meta.fns = serverBundle.fns;
1045
2033
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
1046
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
+ }
1047
2060
  }
1048
2061
  form.set("monty", JSON.stringify(meta));
1049
2062
  let total = 0;
@@ -1065,13 +2078,21 @@ async function deploy() {
1065
2078
  }
1066
2079
  console.log(`origin: ${body.origin}`);
1067
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
+ }
1068
2089
  }
1069
2090
 
1070
2091
  // Bundle server/index.ts (if present) into ONE Worker script: a generated
1071
2092
  // entry wraps the app's exports with @montytools/sdk/fn-worker's makeFnWorker,
1072
2093
  // esbuild bundles it for workerd. node: imports are rejected at compile time
1073
2094
  // — Live runs on Cloudflare Workers, not Node. Returns { code, fns } or null.
1074
- async function bundleServerFns(appDir) {
2095
+ async function bundleServerFns(appDir, schedule) {
1075
2096
  const serverEntry = join(appDir, "server", "index.ts");
1076
2097
  if (!existsSync(serverEntry)) return null;
1077
2098
  const { build } = await import("esbuild");
@@ -1079,10 +2100,13 @@ async function bundleServerFns(appDir) {
1079
2100
  mkdirSync(tmpDir, { recursive: true });
1080
2101
  const entry = join(tmpDir, "fn-worker-entry.mjs");
1081
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.
1082
2106
  writeFileSync(entry, [
1083
2107
  `import * as appFns from "../server/index";`,
1084
2108
  `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
1085
- `export default makeFnWorker(appFns);`,
2109
+ `export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
1086
2110
  ].join("\n"));
1087
2111
  // Fail the deploy if server code reaches for Node built-ins — a Worker
1088
2112
  // can't run them, and a silent runtime crash on Live is the worst outcome.
@@ -1180,9 +2204,70 @@ function walk(dir) {
1180
2204
  return out;
1181
2205
  }
1182
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
+
1183
2263
  // ── dispatch ───────────────────────────────────────────────────────────────
1184
2264
  // Keep agent skills fresh on every invocation (user level + current app).
1185
- 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
+ }
1186
2271
 
1187
2272
  switch (command) {
1188
2273
  case "login":
@@ -1191,9 +2276,22 @@ switch (command) {
1191
2276
  case "create":
1192
2277
  await create();
1193
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;
1194
2289
  case "dev":
1195
2290
  await dev();
1196
2291
  break;
2292
+ case "logs":
2293
+ await logs();
2294
+ break;
1197
2295
  case "add":
1198
2296
  await add();
1199
2297
  break;
@@ -1233,16 +2331,20 @@ switch (command) {
1233
2331
  await secret();
1234
2332
  break;
1235
2333
  default:
1236
- 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>");
1237
2335
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
1238
- console.log(" create <slug> [--name N] [--icon I] [--build ID] stamp a new app into ~/Monty/<slug>");
1239
- console.log(" dev [--port N] run the app in Studio 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)");
1240
2342
  console.log(" add <name...> install curated UI components (see `monty components`)");
1241
2343
  console.log(" components [query] list the curated component catalog");
1242
2344
  console.log(" docs <name> view a component's source before installing");
1243
2345
  console.log(" current which app folder am I in?");
1244
2346
  console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
1245
- console.log(" apps list local apps in ~/Monty");
2347
+ console.log(" apps list local apps (~/.monty/apps + legacy ~/Monty)");
1246
2348
  console.log(" install install app dependencies");
1247
2349
  console.log(" build production build (vite, via monty)");
1248
2350
  console.log(" typecheck typecheck (builds first if needed)");