@montytools/cli 0.2.10 → 0.4.1

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,25 +4,54 @@
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";
12
- import { basename, dirname, join, relative } from "node:path";
14
+ import { basename, dirname, join, relative, resolve } 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";
16
18
  import { CompileError, compileAppConfig } from "../lib/compile.mjs";
17
19
 
18
- const CONFIG_DIR = join(homedir(), ".monty");
20
+ // MONTY_HOME overrides the state root (default ~/.monty): config.json,
21
+ // apps/, and desktop.json all live under it. This is how a second, isolated
22
+ // Monty state coexists on one machine — the desktop's dev channel points it
23
+ // at ~/.monty-dev so platform development never touches the real state. A
24
+ // custom root is a sandbox: the legacy visible home (~/Monty) is not scanned.
25
+ const CONFIG_DIR = process.env.MONTY_HOME
26
+ ? resolve(process.env.MONTY_HOME)
27
+ : join(homedir(), ".monty");
19
28
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
20
29
  const DEFAULT_HOST = "https://usemonty.dev";
21
30
  const DEV_SESSION_HEARTBEAT_MS = 30_000;
22
31
  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");
32
+ // The local session contract (.monty/dev.json + dev.log): the running dev
33
+ // shell advertises itself so a second `monty dev` (an agent, or the Monty
34
+ // desktop) attaches instead of superseding, and `monty logs` reads the log.
35
+ const DEV_JSON_STALE_MS = 90_000; // freshness window for dev.json.updatedAt
36
+ const DEV_JSON_TOUCH_MS = 15_000; // dedicated updatedAt cadence (NOT the heartbeat — that starts minutes late, or never when logged out)
37
+ const DEV_LOG_MAX_BYTES = 8 * 1024 * 1024; // rotate dev.log at 8 MiB (disk bound ≈ 16 MiB with dev.log.1)
38
+ const LOGS_DEFAULT_LINES = 50;
39
+ const LOGS_POLL_MS = 300;
40
+ const ATTACH_TAIL_LINES = 10;
41
+ const TAKEOVER_WAIT_MS = 5_000; // grace per phase (SIGTERM, then SIGKILL)
42
+ // eslint-disable-next-line no-control-regex -- dev.log is ANSI-free by contract
43
+ const ANSI_RE = new RegExp(
44
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
45
+ "g",
46
+ );
47
+ // Every app's source lives in one predictable, hidden place: `monty create`
48
+ // registers the app first and the server-minted id names the folder
49
+ // (~/.monty/apps/<id>) — id-keyed because slugs may be renamed later; the
50
+ // `id` stamped into monty.config.ts is the durable identity. `monty login`
51
+ // provisions the home; --dir overrides per create. Pre-id apps in the legacy
52
+ // visible home (~/Monty) keep working — every scan reads both.
53
+ const MONTY_HOME = join(CONFIG_DIR, "apps");
54
+ const LEGACY_MONTY_HOME = join(homedir(), "Monty");
26
55
 
27
56
  const [, , command, ...rest] = process.argv;
28
57
 
@@ -297,12 +326,28 @@ function readSlugFromConfig(dir) {
297
326
  }
298
327
  }
299
328
 
300
- function listLocalApps() {
301
- if (!existsSync(MONTY_HOME)) return [];
302
- return readdirSync(MONTY_HOME)
303
- .map((name) => join(MONTY_HOME, name))
329
+ function readIdFromConfig(dir) {
330
+ try {
331
+ return /^[ \t]*id:\s*"([^"]+)"/m.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ function scanAppsHome(root) {
338
+ if (!existsSync(root)) return [];
339
+ return readdirSync(root)
340
+ .map((name) => join(root, name))
304
341
  .filter((p) => existsSync(join(p, "monty.config.ts")))
305
- .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p) }));
342
+ .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
343
+ }
344
+
345
+ function listLocalApps() {
346
+ // A MONTY_HOME sandbox lists only its own apps — leaking the legacy home
347
+ // into an isolated root would defeat the isolation.
348
+ return process.env.MONTY_HOME
349
+ ? scanAppsHome(MONTY_HOME)
350
+ : [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
306
351
  }
307
352
 
308
353
  function current() {
@@ -311,8 +356,10 @@ function current() {
311
356
  fail("NOT_IN_APP", `You are not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one.`);
312
357
  }
313
358
  console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
359
+ const id = readIdFromConfig(root);
360
+ if (id) console.log(`id: ${id}`);
314
361
  console.log(`path: ${root}`);
315
- if (!root.startsWith(MONTY_HOME)) {
362
+ if (!root.startsWith(MONTY_HOME) && !root.startsWith(LEGACY_MONTY_HOME)) {
316
363
  console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
317
364
  }
318
365
  }
@@ -323,10 +370,10 @@ function select() {
323
370
  fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
324
371
  }
325
372
  const apps = listLocalApps();
326
- const hit = apps.find((a) => a.slug === slug || basename(a.path) === slug);
373
+ const hit = apps.find((a) => a.slug === slug || a.id === slug || basename(a.path) === slug);
327
374
  if (!hit) {
328
375
  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}\`.`);
376
+ fail("APP_NOT_LOCAL", `No local source for "${slug}" on this machine. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
330
377
  }
331
378
  // Bare path on stdout so command substitution works: cd "$(monty select x)"
332
379
  console.log(hit.path);
@@ -341,22 +388,267 @@ function apps() {
341
388
  for (const a of local) console.log(`${a.slug}\t${a.path}`);
342
389
  }
343
390
 
391
+ // Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
392
+ // tar.gz buffer + its sha256 — the snapshot unit `monty commit` and deploys
393
+ // both upload. gzip runs with -n (no embedded timestamp) so an UNCHANGED
394
+ // tree packs to identical bytes — that's what makes "nothing to commit"
395
+ // detectable by hash. Returns null when packing fails; { tooLarge } past
396
+ // the 10MB cap.
397
+ function packSource(appDir) {
398
+ mkdirSync(join(appDir, ".monty"), { recursive: true });
399
+ const srcTar = join(appDir, ".monty", "source-upload.tar.gz");
400
+ const excludes = ["./node_modules", "./dist", "./.monty", "./.git", "./.env.local", "./release"]
401
+ .map((p) => `--exclude ${JSON.stringify(p)}`)
402
+ .join(" ");
403
+ const packRes =
404
+ process.platform === "win32"
405
+ ? spawnSync("tar", ["-czf", srcTar, "--exclude", "./node_modules", "--exclude", "./dist", "--exclude", "./.monty", "--exclude", "./.git", "--exclude", "./.env.local", "--exclude", "./release", "-C", appDir, "."], { stdio: "pipe" })
406
+ : spawnSync("sh", ["-c", `tar -cf - ${excludes} -C ${JSON.stringify(appDir)} . | gzip -n > ${JSON.stringify(srcTar)}`], { stdio: "pipe" });
407
+ if (packRes.status !== 0) {
408
+ rmSync(srcTar, { force: true });
409
+ return null;
410
+ }
411
+ const buf = readFileSync(srcTar);
412
+ rmSync(srcTar, { force: true });
413
+ if (buf.byteLength > 10 * 1024 * 1024) return { tooLarge: true };
414
+ return { buf, hash: createHash("sha256").update(buf).digest("hex") };
415
+ }
416
+
417
+ function readSlug(appDir) {
418
+ try {
419
+ return /slug:\s*"([^"]+)"/.exec(readFileSync(join(appDir, "monty.config.ts"), "utf8"))?.[1] ?? null;
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+
425
+ // ── monty commit ───────────────────────────────────────────────────────────
426
+ // Version the app's source WITHOUT publishing: pack the tree, upload it as
427
+ // one line of history. Git commit with everything stripped except "track
428
+ // versions" — no branches, no diffs, no local repo; history lives in the
429
+ // workspace and survives this folder.
430
+ async function commit() {
431
+ const appDir = requireAppDir("commit");
432
+ const slug = readSlug(appDir);
433
+ if (!slug) fail("NO_SLUG", "monty.config.ts has no slug — fix the config, then retry.");
434
+ const { host, key } = loadConfig();
435
+ if (!key) fail("NOT_LOGGED_IN", "Committing stores the snapshot in your workspace. Run `monty login` first.");
436
+ const mIdx = rest.indexOf("-m");
437
+ const message = flag("message") ?? (mIdx >= 0 ? rest[mIdx + 1] : undefined) ?? "checkpoint";
438
+ const packed = packSource(appDir);
439
+ if (packed === null) fail("PACK_FAILED", "Packing the source failed (tar error). Retry; check the folder is readable.");
440
+ if (packed.tooLarge) fail("SOURCE_TOO_LARGE", "The source tree exceeds 10 MB (node_modules/dist excluded). Remove large assets, then retry.");
441
+ try {
442
+ const stamp = JSON.parse(readFileSync(join(appDir, ".monty", "source.json"), "utf8"));
443
+ if (stamp.hash === packed.hash) {
444
+ console.log(`nothing to commit — source unchanged since ${packed.hash.slice(0, 7)}`);
445
+ return;
446
+ }
447
+ } catch {
448
+ /* no stamp yet — first commit from this folder */
449
+ }
450
+ const form = new FormData();
451
+ form.set("monty", JSON.stringify({ slug, hash: packed.hash, message }));
452
+ form.set("source", new Blob([packed.buf]), "source.tar.gz");
453
+ const res = await fetch(`${host}/api/source`, {
454
+ method: "POST",
455
+ headers: { authorization: `Bearer ${key}` },
456
+ body: form,
457
+ });
458
+ const body = await res.json().catch(() => null);
459
+ if (!res.ok || !body?.ok) {
460
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Uploading the snapshot failed — check the connection and retry.");
461
+ }
462
+ writeFileSync(
463
+ join(appDir, ".monty", "source.json"),
464
+ JSON.stringify({ hash: packed.hash, syncedAt: Date.now() }) + "\n",
465
+ );
466
+ console.log(`committed: ${packed.hash.slice(0, 7)} "${message}" (${(packed.buf.byteLength / 1024).toFixed(0)} KB)`);
467
+ }
468
+
469
+ // ── monty log ──────────────────────────────────────────────────────────────
470
+ // The app's version history, newest first. Commits and publishes share one
471
+ // timeline. (Not `monty logs` — that tails the dev shell.)
472
+ async function versionsLog() {
473
+ const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
474
+ if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
475
+ const { host, key } = loadConfig();
476
+ if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
477
+ const res = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
478
+ headers: { authorization: `Bearer ${key}` },
479
+ });
480
+ const body = await res.json().catch(() => null);
481
+ if (!res.ok || !body?.ok) {
482
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not list versions — check the connection and `monty login`.");
483
+ }
484
+ if (body.versions.length === 0) {
485
+ console.log(`no versions of "${slug}" yet — \`monty commit\` or a publish creates the first one.`);
486
+ return;
487
+ }
488
+ for (const v of body.versions) {
489
+ const when = new Date(v.createdAt).toISOString().slice(0, 16).replace("T", " ");
490
+ console.log(`${v.hash.slice(0, 7)} ${when} ${v.published ? "[published] " : ""}${v.message}`);
491
+ }
492
+ console.log(`\nrestore one: monty pull ${slug} --version <hash> [--force]`);
493
+ }
494
+
495
+ // ── monty pull ─────────────────────────────────────────────────────────────
496
+ // Restore an app's published source snapshot onto this machine. Every
497
+ // `monty deploy`/publish uploads the source tree beside the bundle; pull is
498
+ // how a second machine (or one that lost the folder) gets the code back.
499
+ // Refuses to touch an existing folder without --force — it may hold
500
+ // unpublished work the snapshot would destroy.
501
+ async function pull() {
502
+ const slug = rest.find((a) => !a.startsWith("--"));
503
+ if (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
504
+ fail("INVALID_SLUG", "Usage: monty pull <slug> [--force]");
505
+ }
506
+ const { host, key } = loadConfig();
507
+ if (!key) {
508
+ fail("NOT_LOGGED_IN", `Pulling needs your workspace (${host}). Run \`monty login\` first.`);
509
+ }
510
+ const appsRes = await fetch(`${host}/api/apps`, { headers: { authorization: `Bearer ${key}` } });
511
+ const appsBody = await appsRes.json().catch(() => null);
512
+ if (!appsRes.ok || !appsBody?.ok) {
513
+ fail(appsBody?.code ?? `HTTP_${appsRes.status}`, appsBody?.fix ?? "Could not list workspace apps — check the connection and `monty login`.");
514
+ }
515
+ const app = appsBody.apps.find((a) => a.slug === slug);
516
+ if (!app) {
517
+ fail("APP_NOT_FOUND", `No app "${slug}" in this workspace. \`monty apps\` lists what exists.`);
518
+ }
519
+ // --version <hash-prefix>: restore a specific snapshot from `monty log`
520
+ // instead of the newest one. Prefixes resolve against the history list.
521
+ const versionFlag = flag("version");
522
+ let expectedHash = app.sourceHash;
523
+ let downloadUrl = `${host}/api/source?slug=${slug}`;
524
+ if (versionFlag) {
525
+ const vres = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
526
+ headers: { authorization: `Bearer ${key}` },
527
+ });
528
+ const vbody = await vres.json().catch(() => null);
529
+ if (!vres.ok || !vbody?.ok) {
530
+ fail(vbody?.code ?? `HTTP_${vres.status}`, vbody?.fix ?? "Could not list versions — retry.");
531
+ }
532
+ const matches = vbody.versions.filter((v) => v.hash.startsWith(versionFlag));
533
+ if (matches.length === 0) {
534
+ fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty log ${slug}\` lists what exists.`);
535
+ }
536
+ if (matches.length > 1) {
537
+ fail("VERSION_AMBIGUOUS", `"${versionFlag}" matches ${matches.length} versions — use more characters of the hash.`);
538
+ }
539
+ expectedHash = matches[0].hash;
540
+ downloadUrl = `${host}/api/source?slug=${slug}&hash=${expectedHash}`;
541
+ } else if (!app.sourceHash) {
542
+ 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.`);
543
+ }
544
+ const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
545
+ if (existsSync(target) && !rest.includes("--force")) {
546
+ 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.`);
547
+ }
548
+
549
+ console.log(`pull: ${slug}${versionFlag ? ` @ ${expectedHash.slice(0, 7)}` : ""} <- ${host}`);
550
+ const res = await fetch(downloadUrl, {
551
+ headers: { authorization: `Bearer ${key}` },
552
+ });
553
+ if (!res.ok) {
554
+ const b = await res.json().catch(() => null);
555
+ fail(b?.code ?? `HTTP_${res.status}`, b?.fix ?? "Downloading the snapshot failed — retry.");
556
+ }
557
+ const buf = Buffer.from(await res.arrayBuffer());
558
+ const hash = createHash("sha256").update(buf).digest("hex");
559
+ if (hash !== expectedHash) {
560
+ fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, republish the app from a machine that has the source.");
561
+ }
562
+
563
+ // Extract into a staging folder, then move into place — a failed extract
564
+ // never leaves a half-written app folder.
565
+ const staging = `${target}.pull-tmp`;
566
+ rmSync(staging, { recursive: true, force: true });
567
+ mkdirSync(staging, { recursive: true });
568
+ const tarFile = join(staging, ".source.tar.gz");
569
+ writeFileSync(tarFile, buf);
570
+ const untar = spawnSync("tar", ["-xzf", tarFile, "-C", staging], { stdio: "pipe" });
571
+ rmSync(tarFile, { force: true });
572
+ if (untar.status !== 0) {
573
+ rmSync(staging, { recursive: true, force: true });
574
+ fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, republish the app.");
575
+ }
576
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
577
+ renameSync(staging, target);
578
+
579
+ // Same follow-ups as create: client env from the platform + the sync stamp.
580
+ try {
581
+ const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
582
+ writeFileSync(
583
+ join(target, ".env.local"),
584
+ `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
585
+ );
586
+ } catch {
587
+ console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
588
+ }
589
+ mkdirSync(join(target, ".monty"), { recursive: true });
590
+ writeFileSync(
591
+ join(target, ".monty", "source.json"),
592
+ JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
593
+ );
594
+ console.log(`pulled: ${target}`);
595
+ console.log("next: `monty install`, then `monty dev`.");
596
+ }
597
+
344
598
  // ── monty create ───────────────────────────────────────────────────────────
345
599
  async function create() {
346
600
  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").');
601
+ if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
602
+ fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens, max 64 chars (e.g. "standup-notes").');
603
+ }
604
+ // An app with this slug already on disk means create is the wrong verb —
605
+ // fail BEFORE registering, and never suggest deleting anything: the folder
606
+ // may hold real, uncommitted work.
607
+ const dupe = listLocalApps().find((a) => a.slug === slug);
608
+ if (dupe) {
609
+ 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
610
  }
350
611
  const name =
351
612
  flag("name") ??
352
613
  slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
353
614
  const icon = flag("icon") ?? "layout-grid";
354
- // Apps live in ~/Monty/<slug> unless --dir points elsewhere.
615
+
616
+ // Step 0: register the app — the DB mints the id that names the local
617
+ // folder and rides monty.config.ts, and it arbitrates slug uniqueness
618
+ // workspace-wide. Creating is therefore online + logged-in, by design.
619
+ const { host, key } = loadConfig();
620
+ if (!key) {
621
+ fail("NOT_LOGGED_IN", `Creating an app registers it in your workspace (${host}). Run \`monty login\` first.`);
622
+ }
623
+ let appId;
624
+ try {
625
+ const r = await fetch(`${host}/api/apps`, {
626
+ method: "POST",
627
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
628
+ body: JSON.stringify({ slug, name, icon }),
629
+ signal: AbortSignal.timeout(15_000),
630
+ });
631
+ const data = await r.json().catch(() => null);
632
+ // The id names a folder and is stamped into a TS file — accept only a
633
+ // plain Convex-id-shaped token, never anything path- or quote-capable.
634
+ if (!r.ok || !data?.ok || typeof data.appId !== "string" || !/^[a-z0-9]{10,64}$/i.test(data.appId)) {
635
+ fail(
636
+ data?.code ?? "CREATE_FAILED",
637
+ data?.fix ?? `Registering the app with ${host} failed (status ${r.status}). Retry; if it persists, run \`monty login\` again.`,
638
+ );
639
+ }
640
+ appId = data.appId;
641
+ } catch {
642
+ 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.`);
643
+ }
644
+ console.log(`registered: ${slug} (id ${appId})`);
645
+
646
+ // Source lands in the id-keyed hidden home unless --dir points elsewhere.
355
647
  const target = flag("dir")
356
648
  ? join(process.cwd(), flag("dir"))
357
- : join(MONTY_HOME, slug);
649
+ : join(MONTY_HOME, appId);
358
650
  if (existsSync(target)) {
359
- fail("DIR_EXISTS", `${target} already exists. Pick another slug or remove the directory.`);
651
+ fail("DIR_EXISTS", `${target} already exists. Remove it (a previous create for "${slug}" left it behind), then retry.`);
360
652
  }
361
653
  mkdirSync(dirname(target), { recursive: true });
362
654
 
@@ -380,12 +672,26 @@ async function create() {
380
672
  },
381
673
  });
382
674
 
383
- // Stamp identity into the copied files.
675
+ // The bundled template ships `.gitignore` as `gitignore` (npm-packlist
676
+ // hard-excludes the real name from tarballs); the in-repo template has the
677
+ // real file. Normalize, and backfill for bundles that carried neither —
678
+ // without a .gitignore, tailwind v4's content scan includes .monty/ and
679
+ // full-reloads Studio on every dev.json touch.
680
+ const gitignorePath = join(target, ".gitignore");
681
+ if (existsSync(join(target, "gitignore"))) {
682
+ renameSync(join(target, "gitignore"), gitignorePath);
683
+ }
684
+ if (!existsSync(gitignorePath)) {
685
+ writeFileSync(gitignorePath, "node_modules/\ndist/\n.monty/\n.env.local\n.env\n");
686
+ }
687
+
688
+ // Stamp identity into the copied files. The id line is INSERTED (the
689
+ // template ships without one — only real creates have a server id).
384
690
  const configPath = join(target, "monty.config.ts");
385
691
  writeFileSync(
386
692
  configPath,
387
693
  readFileSync(configPath, "utf8")
388
- .replace(/slug: "[^"]*"/, `slug: "${slug}"`)
694
+ .replace(/^([ \t]*)slug: "[^"]*"/m, `$1id: "${appId}",\n$1slug: "${slug}"`)
389
695
  .replace(/name: "[^"]*"/, `name: "${name}"`)
390
696
  .replace(/icon: "[^"]*"/, `icon: "${icon}"`),
391
697
  );
@@ -399,8 +705,27 @@ async function create() {
399
705
  readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
400
706
  );
401
707
 
708
+ // The user's brief (--description, e.g. from the desktop's create dialog)
709
+ // goes to the TOP of AGENTS.md — agent harnesses can't be handed an initial
710
+ // prompt portably, but they all read the project instruction file. CLAUDE.md
711
+ // symlinks to AGENTS.md so claude sees the same brief codex/opencode do.
712
+ const description = flag("description");
713
+ const agentsPath = join(target, "AGENTS.md");
714
+ if (description?.trim() && existsSync(agentsPath)) {
715
+ 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`;
716
+ writeFileSync(agentsPath, brief + readFileSync(agentsPath, "utf8"));
717
+ console.log("brief: AGENTS.md carries the app description");
718
+ }
719
+ if (!existsSync(join(target, "CLAUDE.md"))) {
720
+ try {
721
+ symlinkSync("AGENTS.md", join(target, "CLAUDE.md"));
722
+ } catch {
723
+ // Symlinks need privileges on Windows — Claude Code's @import reads the same.
724
+ writeFileSync(join(target, "CLAUDE.md"), "@AGENTS.md\n");
725
+ }
726
+ }
727
+
402
728
  // Client config comes from the platform — public values, no dashboard trip.
403
- const host = loadConfig()?.host ?? DEFAULT_HOST;
404
729
  try {
405
730
  const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
406
731
  writeFileSync(
@@ -418,18 +743,15 @@ async function create() {
418
743
  if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
419
744
  mkdirSync(join(target, ".monty"), { recursive: true });
420
745
  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
- }
746
+ try {
747
+ await fetch(`${host}/api/build`, {
748
+ method: "POST",
749
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
750
+ body: JSON.stringify({ buildId, slug }),
751
+ });
752
+ console.log("build: workspace notified — the New app screen is following along");
753
+ } catch {
754
+ /* progress signal only never block create */
433
755
  }
434
756
  }
435
757
 
@@ -497,7 +819,7 @@ async function freePort(start) {
497
819
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
498
820
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
499
821
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
500
- const MIN_SDK = "0.1.3";
822
+ const MIN_SDK = "0.1.5";
501
823
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
502
824
 
503
825
  function installedSdkVersion(appDir) {
@@ -556,6 +878,353 @@ function syncSdkViteCache(appDir) {
556
878
  writeFileSync(stampPath, `${v}\n`);
557
879
  }
558
880
 
881
+ // ── the local dev-session contract ─────────────────────────────────────────
882
+ // The running dev shell advertises itself in <app>/.monty/dev.json (atomic
883
+ // tmp+rename writes, a 15s touch timer drives updatedAt) and tees everything
884
+ // it prints into <app>/.monty/dev.log. That file pair is the same-machine
885
+ // contract shared by a second `monty dev` (attaches instead of superseding),
886
+ // `monty logs`, and the Monty desktop. Advisory only — cross-machine
887
+ // arbitration stays with the platform's session lock.
888
+
889
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
890
+
891
+ function devPaths(appDir) {
892
+ const dir = join(appDir, ".monty");
893
+ return { dir, json: join(dir, "dev.json"), log: join(dir, "dev.log"), prevLog: join(dir, "dev.log.1") };
894
+ }
895
+
896
+ async function readDevJson(appDir) {
897
+ const { json } = devPaths(appDir);
898
+ for (let attempt = 0; attempt < 2; attempt++) {
899
+ try {
900
+ return JSON.parse(readFileSync(json, "utf8"));
901
+ } catch (e) {
902
+ if (e.code === "ENOENT") return null;
903
+ await sleep(50); // mid-rename window — settle and retry once
904
+ }
905
+ }
906
+ return { unreadable: true };
907
+ }
908
+
909
+ function pidAlive(pid) {
910
+ try {
911
+ process.kill(pid, 0);
912
+ return true;
913
+ } catch (e) {
914
+ return e.code !== "ESRCH"; // EPERM = exists (another user's process)
915
+ }
916
+ }
917
+
918
+ // LIVE ⇔ pid alive && (updatedAt fresh || vite still answering on the
919
+ // recorded port). The port+HTTP fallback keeps a healthy session attachable
920
+ // right after a laptop wake, before the touch timer's next beat.
921
+ async function checkDevSession(appDir) {
922
+ const s = await readDevJson(appDir);
923
+ if (!s) return { live: false, session: null, reason: "no session" };
924
+ if (s.unreadable) return { live: false, session: null, reason: "unreadable session file" };
925
+ if (!Number.isInteger(s.pid) || s.pid <= 0 || typeof s.sessionId !== "string" || typeof s.updatedAt !== "number") {
926
+ return { live: false, session: s, reason: "malformed session file" };
927
+ }
928
+ if (s.pid === process.pid) return { live: false, session: s, reason: "own pid" };
929
+ if (!pidAlive(s.pid)) return { live: false, session: s, reason: `process ${s.pid} not running` };
930
+ if (Date.now() - s.updatedAt <= DEV_JSON_STALE_MS) return { live: true, session: s };
931
+ if (Number.isInteger(s.port) && ((await portTaken("127.0.0.1", s.port)) || (await portTaken("::1", s.port)))) {
932
+ const probeVite = (origin) =>
933
+ fetch(`${origin}:${s.port}/@vite/client`, { signal: AbortSignal.timeout(1000) })
934
+ .then((r) => r.ok)
935
+ .catch(() => false);
936
+ // Both loopback stacks — a vite bound only to ::1 must still read LIVE.
937
+ if ((await probeVite("http://127.0.0.1")) || (await probeVite("http://[::1]"))) {
938
+ return { live: true, session: s };
939
+ }
940
+ }
941
+ return { live: false, session: s, reason: `not responding (no update for ${Math.round((Date.now() - s.updatedAt) / 1000)}s)` };
942
+ }
943
+
944
+ function readLogTail(appDir, n) {
945
+ try {
946
+ const lines = readFileSync(devPaths(appDir).log, "utf8").split("\n");
947
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
948
+ return lines.slice(-n);
949
+ } catch {
950
+ return [];
951
+ }
952
+ }
953
+
954
+ function fmtDuration(seconds) {
955
+ if (seconds < 60) return `${seconds}s`;
956
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
957
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
958
+ }
959
+
960
+ // dev.json writer for THIS session: atomic tmp+rename, never clobbers or
961
+ // deletes a DIFFERENT session's file (we lost a race / were superseded), and
962
+ // SEALS on removal — a late heartbeat or timer resolving after shutdown must
963
+ // not resurrect a session file for a process that is exiting.
964
+ function makeSessionFile(appDir, sessionId) {
965
+ const { json } = devPaths(appDir);
966
+ const tmp = `${json}.${process.pid}.tmp`;
967
+ let current = null;
968
+ let sealed = false;
969
+ const ownsFile = () => {
970
+ try {
971
+ const onDisk = JSON.parse(readFileSync(json, "utf8"));
972
+ return !onDisk?.sessionId || onDisk.sessionId === sessionId;
973
+ } catch {
974
+ return true; // absent or unreadable — ours to (re)write
975
+ }
976
+ };
977
+ return {
978
+ write(patch) {
979
+ if (sealed) return;
980
+ current = { ...(current ?? {}), ...patch, updatedAt: Date.now() };
981
+ if (!ownsFile()) return;
982
+ try {
983
+ mkdirSync(dirname(json), { recursive: true });
984
+ writeFileSync(tmp, JSON.stringify(current, null, 2) + "\n");
985
+ renameSync(tmp, json);
986
+ } catch {
987
+ /* advisory file — the next touch retries */
988
+ }
989
+ },
990
+ remove() {
991
+ sealed = true;
992
+ if (ownsFile()) rmSync(json, { force: true });
993
+ rmSync(tmp, { force: true });
994
+ },
995
+ };
996
+ }
997
+
998
+ // dev.log writer: SYNCHRONOUS appends — a write can never outlive the
999
+ // session (nothing async to race on shutdown), a full disk degrades to
1000
+ // silent log loss instead of an uncaught stream error, and fail()'s
1001
+ // process.exit cannot drop the final line. Session-start rotation to
1002
+ // dev.log.1, 8 MiB size rotation. Each source() is a line assembler that
1003
+ // buffers partial chunks (UTF-8-safe across chunk boundaries), strips ANSI,
1004
+ // and stamps HH:MM:SS — terminal mirrors always get the ORIGINAL bytes,
1005
+ // only the log is normalized.
1006
+ function openDevLog(appDir) {
1007
+ const { log, prevLog } = devPaths(appDir);
1008
+ mkdirSync(dirname(log), { recursive: true });
1009
+ try {
1010
+ renameSync(log, prevLog);
1011
+ } catch {
1012
+ /* first session in this folder */
1013
+ }
1014
+ let bytes = 0;
1015
+ let closed = false;
1016
+ const write = (line) => {
1017
+ if (closed) return;
1018
+ try {
1019
+ appendFileSync(log, line);
1020
+ } catch {
1021
+ return; /* the log must never take the session down */
1022
+ }
1023
+ bytes += Buffer.byteLength(line);
1024
+ if (bytes >= DEV_LOG_MAX_BYTES) {
1025
+ bytes = 0;
1026
+ try {
1027
+ renameSync(log, prevLog);
1028
+ } catch (e) {
1029
+ if (e.code !== "ENOENT") {
1030
+ // Rotation blocked (e.g. dev.log.1 locked on win32): truncate in
1031
+ // place — bounded disk beats an ever-growing log, and followers
1032
+ // recover via their shrink-reopen rule.
1033
+ try {
1034
+ writeFileSync(log, "");
1035
+ } catch {
1036
+ /* still capped at the next cycle */
1037
+ }
1038
+ }
1039
+ }
1040
+ try {
1041
+ appendFileSync(log, `--- log rotated ${new Date().toISOString()} ---\n`);
1042
+ } catch {
1043
+ /* ignore */
1044
+ }
1045
+ }
1046
+ };
1047
+ const stamp = () => new Date().toTimeString().slice(0, 8);
1048
+ const source = (prefix = "") => {
1049
+ const decoder = new StringDecoder("utf8"); // multi-byte chars split across chunks decode intact
1050
+ let buf = "";
1051
+ const emit = (l) => write(`${stamp()} ${prefix}${l.replace(ANSI_RE, "")}\n`);
1052
+ const fn = (chunk) => {
1053
+ buf += typeof chunk === "string" ? chunk : decoder.write(chunk);
1054
+ const lines = buf.split("\n");
1055
+ buf = lines.pop();
1056
+ lines.forEach(emit);
1057
+ };
1058
+ fn.flush = () => {
1059
+ buf += decoder.end();
1060
+ if (buf) {
1061
+ emit(buf);
1062
+ buf = "";
1063
+ }
1064
+ };
1065
+ return fn;
1066
+ };
1067
+ write(`--- monty dev started ${new Date().toISOString()} (pid ${process.pid}) ---\n`);
1068
+ return { source, path: log, close: () => { closed = true; } };
1069
+ }
1070
+
1071
+ // The vite bin as the APP resolves it, spawned via process.execPath — no npx
1072
+ // wrapper, so child.kill() actually kills vite (and win32 avoids the Node>=22
1073
+ // .cmd EINVAL). createRequire walks node_modules upward, so hoisted installs
1074
+ // (workspace apps like demos/) and pnpm symlink layouts all resolve.
1075
+ function resolveViteBin(appDir) {
1076
+ try {
1077
+ const req = createRequire(join(appDir, "package.json"));
1078
+ const pkgPath = req.resolve("vite/package.json");
1079
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1080
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vite;
1081
+ if (bin) return join(dirname(pkgPath), bin);
1082
+ } catch {
1083
+ /* not installed anywhere up the tree */
1084
+ }
1085
+ return null;
1086
+ }
1087
+
1088
+ // Attach output: a fast status glance for agents. Never blocks, always exit 0.
1089
+ function printAttach(appDir, s) {
1090
+ const up = fmtDuration(Math.max(0, Math.round((Date.now() - (s.startedAt ?? s.updatedAt)) / 1000)));
1091
+ console.log(`dev: already running for "${s.slug ?? "?"}" — attached, nothing to start (pid ${s.pid}, up ${up})`);
1092
+ if (s.state === "starting") {
1093
+ console.log("state: starting (vite not ready yet — `monty logs -f` to watch)");
1094
+ } else {
1095
+ console.log(`ready: ${s.appUrl ?? `http://localhost:${s.port}`}`);
1096
+ if (!s.loggedIn) {
1097
+ console.log("state: local-only (not logged in — run `monty login`, then `monty dev --takeover`)");
1098
+ } else if (s.state === "online") {
1099
+ const beat = typeof s.lastHeartbeatAt === "number" ? Math.round((Date.now() - s.lastHeartbeatAt) / 1000) : null;
1100
+ console.log(
1101
+ beat !== null && beat > Math.round(DEV_JSON_STALE_MS / 1000)
1102
+ ? `state: online (no heartbeat for ${beat}s — Studio may show offline)`
1103
+ : `state: online (heartbeat ${beat ?? "?"}s ago)`,
1104
+ );
1105
+ if (s.studioUrl) console.log(`studio: ${s.studioUrl} — your app runs there while this is up; click Publish to go Live`);
1106
+ } else {
1107
+ console.log("state: ready (registering with the workspace — the Studio link appears on the first successful heartbeat)");
1108
+ }
1109
+ }
1110
+ console.log("logs: `monty logs -f` follows output; `monty logs -n 100` shows recent lines");
1111
+ const tail = readLogTail(appDir, ATTACH_TAIL_LINES);
1112
+ if (tail.length) {
1113
+ console.log(`log: last ${tail.length} line(s) of .monty/dev.log`);
1114
+ for (const l of tail) console.log(l);
1115
+ } else {
1116
+ console.log("log: (no log lines yet)");
1117
+ }
1118
+ if (flag("port")) console.log(`note: --port ignored — session already on :${s.port} (\`monty dev --takeover\` to restart)`);
1119
+ }
1120
+
1121
+ // `monty dev --takeover`: stop the recorded session (SIGTERM → SIGKILL; win32
1122
+ // has no graceful phase — process.kill is TerminateProcess, so go straight to
1123
+ // taskkill /T), free the platform lock with the OLD sessionId (a hard-killed
1124
+ // process can't), and hand the folder to a fresh start.
1125
+ async function performTakeover(appDir, s) {
1126
+ const signalPid = (pid, sig) => {
1127
+ try {
1128
+ process.kill(pid, sig);
1129
+ return true;
1130
+ } catch (e) {
1131
+ return e.code === "ESRCH";
1132
+ }
1133
+ };
1134
+ const gone = async () =>
1135
+ !pidAlive(s.pid) &&
1136
+ !(Number.isInteger(s.port) && ((await portTaken("127.0.0.1", s.port)) || (await portTaken("::1", s.port))));
1137
+ const waitGone = async (ms) => {
1138
+ const until = Date.now() + ms;
1139
+ while (Date.now() < until) {
1140
+ if (await gone()) return true;
1141
+ await sleep(200);
1142
+ }
1143
+ return gone();
1144
+ };
1145
+ console.log(`takeover: stopping session pid ${s.pid}…`);
1146
+ if (process.platform === "win32") {
1147
+ spawnSync("taskkill", ["/pid", String(s.pid), "/T", "/F"], { stdio: "ignore" });
1148
+ if (Number.isInteger(s.vitePid)) spawnSync("taskkill", ["/pid", String(s.vitePid), "/T", "/F"], { stdio: "ignore" });
1149
+ } else if (!signalPid(s.pid, "SIGTERM")) {
1150
+ fail("TAKEOVER_FAILED", `The running session (pid ${s.pid}) belongs to another user. Stop it manually, then rerun \`monty dev\`.`);
1151
+ }
1152
+ let ok = await waitGone(TAKEOVER_WAIT_MS);
1153
+ if (!ok && process.platform !== "win32") {
1154
+ console.log("takeover: SIGTERM ignored — escalating to SIGKILL");
1155
+ signalPid(s.pid, "SIGKILL");
1156
+ if (Number.isInteger(s.vitePid)) signalPid(s.vitePid, "SIGKILL");
1157
+ if (Number.isInteger(s.tunnelPid)) signalPid(s.tunnelPid, "SIGKILL");
1158
+ ok = await waitGone(TAKEOVER_WAIT_MS);
1159
+ }
1160
+ if (!ok) {
1161
+ 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>\`.`);
1162
+ }
1163
+ console.log(`takeover: session stopped, port ${s.port ?? "?"} free`);
1164
+ // Free the platform lock immediately using the OLD session's id AND host —
1165
+ // after a SIGKILL/taskkill the dead process never got to clear it, the
1166
+ // fresh start would otherwise race the 90s TTL, and the old session may
1167
+ // have been registered against a different host than this shell resolves.
1168
+ const sessionHost = typeof s.host === "string" ? s.host.replace(/\/+$/, "") : null;
1169
+ const key = sessionHost ? (normalizedConfig().profiles[sessionHost]?.key ?? null) : null;
1170
+ if (key && typeof s.slug === "string" && typeof s.sessionId === "string") {
1171
+ try {
1172
+ await fetch(`${sessionHost}/api/dev-session`, {
1173
+ method: "POST",
1174
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
1175
+ body: JSON.stringify({ slug: s.slug, sessionId: s.sessionId, end: true }),
1176
+ signal: AbortSignal.timeout(3000),
1177
+ });
1178
+ } catch {
1179
+ /* the bounded claim window covers the TTL race */
1180
+ }
1181
+ }
1182
+ rmSync(devPaths(appDir).json, { force: true }); // ownership death is proven
1183
+ }
1184
+
1185
+ // A dead CLI pid can leave a LIVE orphaned vite (kill -9 skips endSession).
1186
+ // checkDevSession correctly calls that session stale — so --takeover on a
1187
+ // stale file sweeps the recorded child pids when the recorded port is still
1188
+ // busy, instead of abandoning the port forever.
1189
+ async function sweepOrphans(s) {
1190
+ const portBusy = async () =>
1191
+ Number.isInteger(s.port) && ((await portTaken("127.0.0.1", s.port)) || (await portTaken("::1", s.port)));
1192
+ if (!(await portBusy())) return;
1193
+ console.log(`takeover: dead session left port ${s.port} busy — cleaning up its processes`);
1194
+ const signalPid = (pid, sig) => {
1195
+ try {
1196
+ process.kill(pid, sig);
1197
+ } catch {
1198
+ /* already gone or not ours */
1199
+ }
1200
+ };
1201
+ const kids = [s.vitePid, s.tunnelPid].filter((p) => Number.isInteger(p));
1202
+ for (const pid of kids) {
1203
+ if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
1204
+ else signalPid(pid, "SIGTERM");
1205
+ }
1206
+ let until = Date.now() + TAKEOVER_WAIT_MS;
1207
+ while (Date.now() < until) {
1208
+ if (!(await portBusy())) {
1209
+ console.log(`takeover: port ${s.port} free`);
1210
+ return;
1211
+ }
1212
+ await sleep(200);
1213
+ }
1214
+ if (process.platform !== "win32") {
1215
+ for (const pid of kids) signalPid(pid, "SIGKILL");
1216
+ until = Date.now() + TAKEOVER_WAIT_MS;
1217
+ while (Date.now() < until) {
1218
+ if (!(await portBusy())) {
1219
+ console.log(`takeover: port ${s.port} free`);
1220
+ return;
1221
+ }
1222
+ await sleep(200);
1223
+ }
1224
+ }
1225
+ console.log(`warn: port ${s.port} is still busy after cleanup — picking another port`);
1226
+ }
1227
+
559
1228
  // ── monty dev ──────────────────────────────────────────────────────────────
560
1229
  // Puts the app in Studio: vite locally + a Cloudflare quick tunnel registered
561
1230
  // as the app's STUDIO channel, so workspace admins see the app (HMR included)
@@ -564,6 +1233,51 @@ function syncSdkViteCache(appDir) {
564
1233
  // clicks Publish in the workspace, this process builds + uploads to Live.
565
1234
  async function dev() {
566
1235
  const appDir = requireAppDir("dev");
1236
+
1237
+ // Attach check FIRST — before skills/sdk/compile — so a second `monty dev`
1238
+ // is a fast, harmless status glance and can never mutate node_modules
1239
+ // under a live session's vite.
1240
+ const takeover = rest.includes("--takeover");
1241
+ const probe = await checkDevSession(appDir);
1242
+ if (probe.live && !takeover) {
1243
+ printAttach(appDir, probe.session);
1244
+ process.exit(0);
1245
+ }
1246
+ if (probe.live && takeover) {
1247
+ await performTakeover(appDir, probe.session);
1248
+ } else if (probe.session) {
1249
+ console.log(`dev: stale session file from pid ${probe.session.pid} (${probe.reason}) — starting fresh`);
1250
+ if (takeover) {
1251
+ // The stale session's children may have survived it (kill -9 skips
1252
+ // endSession) — sweep them so the recorded port is reclaimable.
1253
+ await sweepOrphans(probe.session);
1254
+ } else if (Number.isInteger(probe.session.port) && (await portTaken("127.0.0.1", probe.session.port))) {
1255
+ console.log(`warn: port ${probe.session.port} is still busy (orphaned vite?) — picking another port; \`monty dev --takeover\` cleans it up`);
1256
+ }
1257
+ rmSync(devPaths(appDir).json, { force: true });
1258
+ } else if (takeover) {
1259
+ console.log("takeover: no running session — starting normally");
1260
+ }
1261
+
1262
+ // Open the log and capture our own output BEFORE the slow steps, so sdk
1263
+ // installs and compile failures land in dev.log for `monty logs`.
1264
+ const logSink = openDevLog(appDir);
1265
+ const cliTee = logSink.source();
1266
+ {
1267
+ const origLog = console.log.bind(console);
1268
+ const origErr = console.error.bind(console);
1269
+ console.log = (...a) => {
1270
+ origLog(...a);
1271
+ cliTee(a.join(" ") + "\n");
1272
+ };
1273
+ console.error = (...a) => {
1274
+ origErr(...a);
1275
+ cliTee(a.join(" ") + "\n");
1276
+ };
1277
+ }
1278
+ console.log("logs: .monty/dev.log (follow with `monty logs -f`)");
1279
+
1280
+ installSkills({ appDir });
567
1281
  ensureSdk(appDir);
568
1282
  const meta = await compileConfig(appDir);
569
1283
  const cfg = loadConfig();
@@ -573,16 +1287,26 @@ async function dev() {
573
1287
  const requested = flag("port");
574
1288
  const port = requested ? Number(requested) : await freePort(5173);
575
1289
 
1290
+ const viteBin = resolveViteBin(appDir);
1291
+ if (!viteBin) {
1292
+ fail("VITE_MISSING", "vite is not installed in this app. Run `monty install`, then `monty dev` again.");
1293
+ }
1294
+
576
1295
  console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
577
- const child = spawn("npx", ["vite", "dev", "--port", String(port), "--strictPort"], {
1296
+ const child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
578
1297
  cwd: appDir,
579
- stdio: ["ignore", "pipe", "inherit"],
1298
+ stdio: ["ignore", "pipe", "pipe"],
580
1299
  });
581
1300
 
582
1301
  let tunnelChild = null;
1302
+ let pubChild = null;
583
1303
  let hbTimer = null;
1304
+ let touchTimer = null;
1305
+ let cronTimer = null;
584
1306
  let publishing = false;
585
1307
  let ended = false;
1308
+ let registeredOnce = false;
1309
+ const devStartedAt = Date.now();
586
1310
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
587
1311
  const buildFile = join(appDir, ".monty", "build");
588
1312
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
@@ -593,6 +1317,72 @@ async function dev() {
593
1317
  const configPath = join(appDir, "monty.config.ts");
594
1318
  let configMtime = statSync(configPath).mtimeMs;
595
1319
 
1320
+ // Advertise this session. The touch timer (not the platform heartbeat,
1321
+ // which starts minutes late or never when logged out) keeps updatedAt
1322
+ // fresh so attach/desktop liveness checks stay honest.
1323
+ const loggedIn = Boolean(cfg?.key);
1324
+ const sf = makeSessionFile(appDir, sessionId);
1325
+ sf.write({
1326
+ version: 1,
1327
+ cli: CLI_VERSION,
1328
+ pid: process.pid,
1329
+ vitePid: child.pid ?? null,
1330
+ tunnelPid: null,
1331
+ port,
1332
+ slug: meta.slug,
1333
+ appDir,
1334
+ host,
1335
+ sessionId,
1336
+ state: "starting",
1337
+ loggedIn,
1338
+ appUrl: `http://localhost:${port}`,
1339
+ tunnelUrl: null,
1340
+ studioUrl: loggedIn ? `${host}/studio/${meta.slug}` : null,
1341
+ previewUrl: loggedIn
1342
+ ? `${host}/studio/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
1343
+ : null,
1344
+ publishing: false,
1345
+ lastHeartbeatAt: null,
1346
+ logFile: logSink.path,
1347
+ startedAt: Date.now(),
1348
+ });
1349
+ touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
1350
+
1351
+ // The STUDIO cron runner: the Live counterpart is a real Cloudflare Cron
1352
+ // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1353
+ // `schedule` entries against the UTC clock once per minute and invokes the
1354
+ // fn through the same /__monty/fn runtime (x-monty-schedule marks the
1355
+ // lane, so ctx.viewer matches Live exactly). Config edits hot-apply via
1356
+ // currentMeta. Fire-and-forget: a failing cron fn prints its instruction
1357
+ // here and never blocks the loop.
1358
+ let lastCronMinute = null;
1359
+ function cronTick() {
1360
+ const sched = currentMeta?.schedule;
1361
+ if (!sched || !loggedIn) return;
1362
+ const now = new Date();
1363
+ const minute = Math.floor(now.getTime() / 60_000);
1364
+ if (minute === lastCronMinute) return;
1365
+ lastCronMinute = minute;
1366
+ for (const [fn, expr] of Object.entries(sched)) {
1367
+ if (!cronMatches(expr, now)) continue;
1368
+ console.log(`cron: "${expr}" → ${fn}() (UTC)`);
1369
+ const t0 = Date.now();
1370
+ fetch(`http://localhost:${port}/__monty/fn/${fn}`, {
1371
+ method: "POST",
1372
+ headers: { "content-type": "application/json", "x-monty-schedule": expr },
1373
+ body: "{}",
1374
+ }).then(async (r) => {
1375
+ if (r.ok) {
1376
+ console.log(`cron: ${fn} ok (${Date.now() - t0}ms)`);
1377
+ } else {
1378
+ const e = await r.json().catch(() => null);
1379
+ console.log(`cron: ${fn} failed [${e?.code ?? r.status}] ${e?.fix ?? ""}`);
1380
+ }
1381
+ }).catch((e) => console.log(`cron: ${fn} unreachable — ${e?.message ?? e}`));
1382
+ }
1383
+ }
1384
+ cronTimer = setInterval(cronTick, 20_000);
1385
+
596
1386
  async function refreshSchemaIfChanged() {
597
1387
  try {
598
1388
  const m = statSync(configPath).mtimeMs;
@@ -625,7 +1415,15 @@ async function dev() {
625
1415
  if (ended) return;
626
1416
  ended = true;
627
1417
  if (hbTimer) clearInterval(hbTimer);
1418
+ if (touchTimer) clearInterval(touchTimer);
1419
+ if (cronTimer) clearInterval(cronTimer);
1420
+ try { pubChild?.kill(); } catch { /* already gone */ }
628
1421
  try { tunnelChild?.kill(); } catch { /* already gone */ }
1422
+ // vite is a direct child (no npx wrapper), so this actually kills it —
1423
+ // a bare SIGTERM from the desktop must never orphan vite on the port.
1424
+ try { child.kill(); } catch { /* already gone */ }
1425
+ sf.remove();
1426
+ logSink.close();
629
1427
  await clearDevSession();
630
1428
  }
631
1429
 
@@ -633,23 +1431,39 @@ async function dev() {
633
1431
  if (ended) return;
634
1432
  ended = true;
635
1433
  if (hbTimer) clearInterval(hbTimer);
1434
+ if (touchTimer) clearInterval(touchTimer);
1435
+ if (cronTimer) clearInterval(cronTimer);
1436
+ try { pubChild?.kill(); } catch { /* already gone */ }
636
1437
  try { tunnelChild?.kill(); } catch { /* already gone */ }
637
1438
  try { child.kill(); } catch { /* already gone */ }
638
1439
  console.log(`dev-session: superseded — ${fix}`);
1440
+ sf.remove(); // guarded — never deletes the new owner's file
1441
+ logSink.close();
639
1442
  setTimeout(() => process.exit(0), 50);
640
1443
  }
641
1444
 
642
1445
  async function heartbeat(originUrl, { claim = false } = {}) {
1446
+ if (ended) return false; // shutdown already ran — no side effects
643
1447
  await refreshSchemaIfChanged();
644
1448
  try {
1449
+ // Re-read the key EVERY beat: the desktop (or a fresh `monty login`)
1450
+ // may have replaced an expired key while this session runs — the
1451
+ // session must heal itself, not beat forever with a dead key.
1452
+ const liveKey = loadConfig()?.key ?? cfg.key;
645
1453
  const r = await fetch(`${host}/api/dev-session`, {
646
1454
  method: "POST",
647
- headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
1455
+ headers: { authorization: `Bearer ${liveKey}`, "content-type": "application/json" },
648
1456
  body: JSON.stringify({
649
1457
  slug: meta.slug,
650
1458
  tunnelUrl: originUrl,
651
1459
  sessionId,
652
- claim,
1460
+ // Keep claiming until the first successful registration, but only
1461
+ // within the lock's own 90s TTL window: after a takeover/crash the
1462
+ // old lock may linger, and a single failed first beat must not
1463
+ // strand this session into DEV_SESSION_SUPERSEDED against a dead
1464
+ // owner. BOUNDED so a never-registering session (broken network)
1465
+ // can't steal the lock from a newer active session forever.
1466
+ claim: claim || (!registeredOnce && Date.now() - devStartedAt < 90_000),
653
1467
  name: currentMeta.name,
654
1468
  icon: currentMeta.icon,
655
1469
  buildId,
@@ -667,17 +1481,41 @@ async function dev() {
667
1481
  return false;
668
1482
  }
669
1483
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
1484
+ // A dead key is a SIGNED-OUT session — advertise it so the desktop
1485
+ // (which owns the session) can surface sign-in instead of letting
1486
+ // this line repeat in a log nobody watches.
1487
+ if (data?.code === "INVALID_CLI_KEY" || data?.code === "MISSING_CLI_KEY") {
1488
+ sf.write({ loggedIn: false });
1489
+ }
670
1490
  return false;
671
1491
  }
672
- if (data?.publishRequested && !publishing) {
1492
+ if (!registeredOnce) {
1493
+ registeredOnce = true;
1494
+ sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
1495
+ } else {
1496
+ sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1497
+ }
1498
+ if (data?.publishRequested && !publishing && !ended) {
673
1499
  publishing = true;
1500
+ sf.write({ publishing: true });
674
1501
  console.log("publish: requested from the workspace — building & uploading…");
1502
+ const pubTee = logSink.source();
675
1503
  await new Promise((resolve) => {
676
- const pub = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
1504
+ pubChild = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
677
1505
  cwd: appDir,
678
- stdio: ["ignore", "inherit", "inherit"],
1506
+ stdio: ["ignore", "pipe", "pipe"],
1507
+ });
1508
+ pubChild.stdout.on("data", (c) => {
1509
+ process.stdout.write(c);
1510
+ pubTee(c);
679
1511
  });
680
- pub.on("exit", (code) => {
1512
+ pubChild.stderr.on("data", (c) => {
1513
+ process.stderr.write(c);
1514
+ pubTee(c);
1515
+ });
1516
+ pubChild.on("exit", (code) => {
1517
+ pubChild = null;
1518
+ pubTee.flush();
681
1519
  console.log(
682
1520
  code === 0
683
1521
  ? "publish: done — the app is Live for the workspace (Studio session continues)"
@@ -687,6 +1525,7 @@ async function dev() {
687
1525
  });
688
1526
  });
689
1527
  publishing = false;
1528
+ sf.write({ publishing: false });
690
1529
  }
691
1530
  return true;
692
1531
  } catch {
@@ -708,6 +1547,9 @@ async function dev() {
708
1547
  const version = ++tunnelVersion;
709
1548
  if (!initial) {
710
1549
  console.log(`tunnel: changed to ${url}`);
1550
+ // The old public URL is dead the moment cloudflared rotated — stop
1551
+ // advertising it while DNS gating runs (it can fail for minutes).
1552
+ sf.write({ tunnelUrl: null });
711
1553
  await clearDevSession();
712
1554
  }
713
1555
  console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
@@ -722,6 +1564,7 @@ async function dev() {
722
1564
  return "failed";
723
1565
  }
724
1566
  originUrl = url;
1567
+ sf.write({ tunnelUrl: url });
725
1568
  console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
726
1569
  if (!initial && !(await heartbeat(originUrl))) {
727
1570
  console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
@@ -738,8 +1581,12 @@ async function dev() {
738
1581
  };
739
1582
  if (!rest.includes("--no-tunnel")) {
740
1583
  console.log("tunnel: starting (cloudflared quick tunnel)…");
741
- const t = await startTunnel(port, registerTunnelUrl);
1584
+ // cloudflared output is teed to dev.log only (the terminal stays quiet,
1585
+ // exactly as today) — post-mortems get the tunnel noise.
1586
+ const cloudflaredTee = logSink.source("cloudflared: ");
1587
+ const t = await startTunnel(port, registerTunnelUrl, cloudflaredTee);
742
1588
  tunnelChild = t.child;
1589
+ sf.write({ tunnelPid: t.child?.pid ?? null });
743
1590
  if (t.url) {
744
1591
  console.log(`tunnel: ${t.url}`);
745
1592
  await activateTunnelUrl(t.url, { initial: true });
@@ -757,17 +1604,37 @@ async function dev() {
757
1604
  }
758
1605
 
759
1606
  let announced = false;
1607
+ const viteOutTee = logSink.source();
1608
+ const viteErrTee = logSink.source();
760
1609
  child.stdout.on("data", (chunk) => {
761
1610
  const text = chunk.toString();
762
1611
  process.stdout.write(text);
763
- if (!announced && /localhost:\d+/.test(text)) {
1612
+ viteOutTee(chunk);
1613
+ // Strip ANSI before scanning: under FORCE_COLOR/colorized environments
1614
+ // (Solo, some CI ptys) vite colors the URL and the escape codes land
1615
+ // BETWEEN "localhost:" and the digits — the raw text never matches.
1616
+ if (!announced && /localhost:\d+/.test(text.replace(ANSI_RE, ""))) {
764
1617
  announced = true;
1618
+ sf.write({ state: "ready" });
765
1619
  console.log(`data: sandboxed to "${meta.slug}#dev" (Studio sandbox; Live records untouched)`);
766
1620
  console.log(`ready: http://localhost:${port}`);
767
1621
  void startDevSession();
768
1622
  }
769
1623
  });
1624
+ // vite stderr is where build errors and the SDK's browser-error mirror
1625
+ // land — piped (was inherit) so `monty logs` sees them too.
1626
+ child.stderr.on("data", (chunk) => {
1627
+ process.stderr.write(chunk);
1628
+ viteErrTee(chunk);
1629
+ });
1630
+ child.on("error", (e) => {
1631
+ void endSession().then(() => {
1632
+ fail("VITE_SPAWN_FAILED", `Could not start vite: ${e?.message ?? e}. Run \`monty install\`, then retry.`);
1633
+ });
1634
+ });
770
1635
  child.on("exit", (code) => {
1636
+ viteOutTee.flush();
1637
+ viteErrTee.flush();
771
1638
  void endSession().then(() => process.exit(code ?? 0));
772
1639
  });
773
1640
  process.on("SIGINT", () => {
@@ -776,9 +1643,132 @@ async function dev() {
776
1643
  process.on("SIGTERM", () => {
777
1644
  void endSession().then(() => process.exit(143));
778
1645
  });
1646
+ // Closing the terminal window (SIGHUP) and unexpected crashes must clean
1647
+ // up too — every stale dev.json is a lie to the next `monty dev`.
1648
+ process.on("SIGHUP", () => {
1649
+ void endSession().then(() => process.exit(129));
1650
+ });
1651
+ process.on("uncaughtException", (e) => {
1652
+ console.error(`dev: unexpected error — ${e?.stack ?? e}`);
1653
+ void endSession().then(() => process.exit(1));
1654
+ });
1655
+ process.on("unhandledRejection", (e) => {
1656
+ console.error(`dev: unexpected error — ${e?.stack ?? e}`);
1657
+ void endSession().then(() => process.exit(1));
1658
+ });
779
1659
  }
780
1660
 
781
1661
 
1662
+ // ── monty logs ─────────────────────────────────────────────────────────────
1663
+ // The agent's window into the (possibly background) dev shell: pure file
1664
+ // reads over .monty/dev.log — no skills refresh, no sdk, no compile, no
1665
+ // network. stdout carries ONLY log lines (every note goes to stderr), so
1666
+ // `monty logs | grep …` stays clean.
1667
+ async function logs() {
1668
+ const appDir = requireAppDir("logs");
1669
+ const { log, prevLog } = devPaths(appDir);
1670
+ const follow = rest.includes("-f") || rest.includes("--follow");
1671
+ const nIdx = rest.indexOf("-n");
1672
+ const n = nIdx >= 0 ? Number(rest[nIdx + 1]) : LOGS_DEFAULT_LINES;
1673
+ if (!Number.isInteger(n) || n < 0 || n > 10000) {
1674
+ fail("LOGS_USAGE", "Usage: monty logs [-n <lines>] [-f] — <lines> is a non-negative integer (default 50).");
1675
+ }
1676
+ const note = (m) => process.stderr.write(`${m}\n`);
1677
+
1678
+ const s = await readDevJson(appDir);
1679
+ const sessionLive =
1680
+ s !== null &&
1681
+ !s.unreadable &&
1682
+ Number.isInteger(s.pid) &&
1683
+ pidAlive(s.pid) &&
1684
+ typeof s.updatedAt === "number" &&
1685
+ Date.now() - s.updatedAt <= DEV_JSON_STALE_MS;
1686
+
1687
+ if (!existsSync(log)) {
1688
+ if (sessionLive && follow) {
1689
+ note("note: dev session starting — waiting for the log file…");
1690
+ } else if (sessionLive) {
1691
+ note("note: dev session starting — no log yet (`monty logs -f` waits for it)");
1692
+ return;
1693
+ } else if (existsSync(prevLog)) {
1694
+ note("note: no dev session is running — the previous session's log is .monty/dev.log.1");
1695
+ return;
1696
+ } else {
1697
+ fail("NO_DEV_LOG", "No dev session has run in this app folder yet. Start one with `monty dev`.");
1698
+ }
1699
+ } else if (!sessionLive && !(await checkDevSession(appDir)).live) {
1700
+ // The cheap pid+fresh check false-negatives right after a laptop wake —
1701
+ // only print the note once the full liveness check (port + vite probe)
1702
+ // agrees the session is gone.
1703
+ note("note: no dev session is running — showing the last session's log (start one with `monty dev`)");
1704
+ }
1705
+
1706
+ let offset = 0;
1707
+ let lastIno = null;
1708
+ if (existsSync(log)) {
1709
+ const content = readFileSync(log, "utf8");
1710
+ offset = Buffer.byteLength(content);
1711
+ try {
1712
+ lastIno = statSync(log).ino;
1713
+ } catch {
1714
+ /* raced a rotation — the poll loop resyncs */
1715
+ }
1716
+ if (n > 0) {
1717
+ const lines = content.split("\n");
1718
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
1719
+ for (const l of lines.slice(-n)) process.stdout.write(`${l}\n`);
1720
+ }
1721
+ }
1722
+ if (!follow) return;
1723
+
1724
+ // Follow by polling the PATH (never a held fd): a rotation shrinks the
1725
+ // file (reopen at 0 — the fresh file starts with a marker, nothing
1726
+ // replays), a restart repopulates the same path, transient ENOENT is the
1727
+ // rename window. fs.watch is deliberately not used (platform-flaky,
1728
+ // inode-bound across rotation).
1729
+ let partial = "";
1730
+ setInterval(() => {
1731
+ let st;
1732
+ try {
1733
+ st = statSync(log);
1734
+ } catch {
1735
+ return;
1736
+ }
1737
+ // A new inode at the same path = rotation or session restart — reopen at
1738
+ // 0 even when the fresh file already grew past our old offset.
1739
+ if (lastIno !== null && st.ino !== lastIno) {
1740
+ offset = 0;
1741
+ partial = "";
1742
+ }
1743
+ lastIno = st.ino;
1744
+ const size = st.size;
1745
+ if (size < offset) {
1746
+ offset = 0;
1747
+ partial = "";
1748
+ }
1749
+ if (size === offset) return;
1750
+ let fd;
1751
+ try {
1752
+ fd = openSync(log, "r");
1753
+ } catch {
1754
+ return;
1755
+ }
1756
+ try {
1757
+ const buf = Buffer.alloc(size - offset);
1758
+ const read = readSync(fd, buf, 0, buf.length, offset);
1759
+ offset += read;
1760
+ const text = partial + buf.toString("utf8", 0, read);
1761
+ const lines = text.split("\n");
1762
+ partial = lines.pop();
1763
+ for (const l of lines) process.stdout.write(`${l}\n`);
1764
+ } finally {
1765
+ closeSync(fd);
1766
+ }
1767
+ }, LOGS_POLL_MS);
1768
+ process.on("SIGINT", () => process.exit(0));
1769
+ process.on("SIGTERM", () => process.exit(0));
1770
+ }
1771
+
782
1772
  // trycloudflare DNS takes up to a couple of minutes to propagate. Registering
783
1773
  // the origin before it resolves would make admins' browsers cache NXDOMAIN
784
1774
  // (macOS negative cache ≈ 30 min of a broken iframe) — so gate on DNS via
@@ -816,13 +1806,15 @@ async function waitForDns(hostname) {
816
1806
 
817
1807
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
818
1808
  // 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) {
1809
+ // Studio then falls back to localhost-only registration. onOutput receives
1810
+ // every chunk (both fds) for the dev.log tee.
1811
+ function startTunnel(port, onUrlChange, onOutput) {
821
1812
  return new Promise((resolve) => {
822
1813
  let child;
823
1814
  try {
824
1815
  child = spawn("npx", ["-y", "cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
825
1816
  stdio: ["ignore", "pipe", "pipe"],
1817
+ shell: process.platform === "win32", // Node >=22 refuses .cmd spawns without it
826
1818
  });
827
1819
  } catch {
828
1820
  return resolve({ child: null, url: null });
@@ -836,7 +1828,9 @@ function startTunnel(port, onUrlChange) {
836
1828
  }, 45_000);
837
1829
  let currentUrl = null;
838
1830
  const scan = (chunk) => {
839
- const urls = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
1831
+ onOutput?.(chunk);
1832
+ // Same ANSI hazard as the vite ready-scan: match on stripped text.
1833
+ const urls = String(chunk).replace(ANSI_RE, "").match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
840
1834
  for (const url of urls) {
841
1835
  if (url === currentUrl) continue;
842
1836
  currentUrl = url;
@@ -1039,11 +2033,54 @@ async function deploy() {
1039
2033
  // 3a) Server functions (optional): bundle server/index.ts into one worker
1040
2034
  // script and ride the SAME deploy. The manifest (fns) goes in meta so
1041
2035
  // the router gates /__monty/fn/* without a lookup.
1042
- const serverBundle = await bundleServerFns(appDir);
2036
+ const serverBundle = await bundleServerFns(appDir, meta.schedule);
2037
+ const publicFns = Array.isArray(meta.publicFns) ? meta.publicFns : [];
2038
+ const scheduleEntries = Object.entries(meta.schedule ?? {});
2039
+ if (!serverBundle && (publicFns.length > 0 || scheduleEntries.length > 0)) {
2040
+ fail("SERVER_DIR_MISSING",
2041
+ "monty.config.ts declares publicFns/schedule, but this app has no server/index.ts. Create it with the named exports, or remove the declarations.");
2042
+ }
1043
2043
  if (serverBundle) {
2044
+ for (const name of publicFns) {
2045
+ if (!serverBundle.fns.includes(name)) {
2046
+ fail("PUBLIC_FN_UNKNOWN",
2047
+ `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.`);
2048
+ }
2049
+ }
2050
+ for (const [name] of scheduleEntries) {
2051
+ if (!serverBundle.fns.includes(name)) {
2052
+ fail("SCHEDULE_UNKNOWN_FN",
2053
+ `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.`);
2054
+ }
2055
+ }
1044
2056
  meta.fns = serverBundle.fns;
1045
2057
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
1046
2058
  console.log(`fns: bundled ${serverBundle.fns.length} server function(s) (${serverBundle.fns.join(", ")})`);
2059
+ if (publicFns.length > 0) {
2060
+ console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
2061
+ }
2062
+ if (scheduleEntries.length > 0) {
2063
+ console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
2064
+ }
2065
+ }
2066
+ // 3b) SOURCE snapshot rides every publish. Without it the platform keeps
2067
+ // only the minified bundle and the sole copy of the app's code is this
2068
+ // folder — delete it and the source is gone forever. The snapshot is what
2069
+ // `monty pull <slug>` restores on any machine, and the publish lands in
2070
+ // the same version history as `monty commit`.
2071
+ let sourceHash = null;
2072
+ {
2073
+ const packed = packSource(appDir);
2074
+ if (packed === null) {
2075
+ console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this publish.");
2076
+ } else if (packed.tooLarge) {
2077
+ console.log("source: WARNING — snapshot exceeds 10 MB, skipped; `monty pull` will not work for this app. Remove large assets from the app folder.");
2078
+ } else {
2079
+ sourceHash = packed.hash;
2080
+ meta.sourceHash = sourceHash;
2081
+ form.set("source", new Blob([packed.buf]), "source.tar.gz");
2082
+ console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this publish (restore anywhere: monty pull ${meta.slug})`);
2083
+ }
1047
2084
  }
1048
2085
  form.set("monty", JSON.stringify(meta));
1049
2086
  let total = 0;
@@ -1065,13 +2102,21 @@ async function deploy() {
1065
2102
  }
1066
2103
  console.log(`origin: ${body.origin}`);
1067
2104
  console.log(`deployed: ${body.url} (version ${body.version})`);
2105
+ // Stamp what was published — pull uses this to tell "unchanged since last
2106
+ // sync" from "locally modified".
2107
+ if (sourceHash) {
2108
+ writeFileSync(
2109
+ join(appDir, ".monty", "source.json"),
2110
+ JSON.stringify({ hash: sourceHash, syncedAt: Date.now() }) + "\n",
2111
+ );
2112
+ }
1068
2113
  }
1069
2114
 
1070
2115
  // Bundle server/index.ts (if present) into ONE Worker script: a generated
1071
2116
  // entry wraps the app's exports with @montytools/sdk/fn-worker's makeFnWorker,
1072
2117
  // esbuild bundles it for workerd. node: imports are rejected at compile time
1073
2118
  // — Live runs on Cloudflare Workers, not Node. Returns { code, fns } or null.
1074
- async function bundleServerFns(appDir) {
2119
+ async function bundleServerFns(appDir, schedule) {
1075
2120
  const serverEntry = join(appDir, "server", "index.ts");
1076
2121
  if (!existsSync(serverEntry)) return null;
1077
2122
  const { build } = await import("esbuild");
@@ -1079,10 +2124,13 @@ async function bundleServerFns(appDir) {
1079
2124
  mkdirSync(tmpDir, { recursive: true });
1080
2125
  const entry = join(tmpDir, "fn-worker-entry.mjs");
1081
2126
  const out = join(tmpDir, "fn-worker-out.mjs");
2127
+ // The schedule map is baked into the bundle: Cloudflare's scheduled()
2128
+ // hands back only the matching cron expression, so the worker needs the
2129
+ // expression→fn mapping at runtime.
1082
2130
  writeFileSync(entry, [
1083
2131
  `import * as appFns from "../server/index";`,
1084
2132
  `import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
1085
- `export default makeFnWorker(appFns);`,
2133
+ `export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
1086
2134
  ].join("\n"));
1087
2135
  // Fail the deploy if server code reaches for Node built-ins — a Worker
1088
2136
  // can't run them, and a silent runtime crash on Live is the worst outcome.
@@ -1180,9 +2228,329 @@ function walk(dir) {
1180
2228
  return out;
1181
2229
  }
1182
2230
 
2231
+ // ── cron matching (the Studio ticker in `monty dev`) ──────────────────────
2232
+ // UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
2233
+ // names (JAN, MON). Deliberately forgiving: an unparsable field simply never
2234
+ // matches locally — Cloudflare is the syntax authority at deploy, so a bad
2235
+ // expression fails there with its own message.
2236
+ 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 };
2237
+ const CRON_DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
2238
+
2239
+ function cronMatches(expr, date) {
2240
+ const fields = String(expr).trim().split(/\s+/);
2241
+ if (fields.length !== 5) return false;
2242
+ const values = [
2243
+ date.getUTCMinutes(),
2244
+ date.getUTCHours(),
2245
+ date.getUTCDate(),
2246
+ date.getUTCMonth() + 1,
2247
+ date.getUTCDay(),
2248
+ ];
2249
+ const bounds = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
2250
+ return fields.every((field, i) => cronFieldMatches(field, values[i], bounds[i], i));
2251
+ }
2252
+
2253
+ function cronFieldMatches(field, value, [lo, hi], idx) {
2254
+ const names = idx === 3 ? CRON_MONTHS : idx === 4 ? CRON_DAYS : null;
2255
+ const num = (t) => {
2256
+ const named = names?.[t.toLowerCase()];
2257
+ if (named !== undefined) return named;
2258
+ const n = Number(t);
2259
+ return Number.isInteger(n) ? n : null;
2260
+ };
2261
+ for (const part of field.split(",")) {
2262
+ const [rangeRaw, stepRaw] = part.split("/");
2263
+ const step = stepRaw === undefined ? 1 : Number(stepRaw);
2264
+ if (!Number.isInteger(step) || step < 1) continue;
2265
+ let from;
2266
+ let to;
2267
+ if (rangeRaw === "*" || rangeRaw === "") {
2268
+ from = lo;
2269
+ to = hi;
2270
+ } else if (rangeRaw.includes("-")) {
2271
+ const [a, b] = rangeRaw.split("-");
2272
+ from = num(a);
2273
+ to = num(b);
2274
+ } else {
2275
+ from = num(rangeRaw);
2276
+ to = stepRaw === undefined ? from : hi; // "5/10": from 5 to max, step 10
2277
+ }
2278
+ if (from === null || to === null || from > to) continue;
2279
+ for (let v = from; v <= to; v += step) {
2280
+ // day-of-week: cron accepts 7 for Sunday alongside 0
2281
+ if (v === value || (idx === 4 && v === 7 && value === 0)) return true;
2282
+ }
2283
+ }
2284
+ return false;
2285
+ }
2286
+
2287
+ // ── monty data ─────────────────────────────────────────────────────────────
2288
+ // The agent verbs for OPERATING an app: read and write its records from any
2289
+ // terminal — no browser, no dev session. Auth is the mk_ key exchanged at
2290
+ // /api/dev-token for a 5-minute workspace token (member lane, org_id from
2291
+ // the verified JWT), then the 6 public records functions over Convex's HTTP
2292
+ // API. `app` picks the records namespace: the plain slug is Live; --studio
2293
+ // targets "{slug}#dev" (the Studio sandbox — same wire literal the dev
2294
+ // shell uses). Results are ONE JSON document on stdout so agents can pipe.
2295
+
2296
+ const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
2297
+
2298
+ // rest, minus flags AND their values — `monty data list leads --app crm`
2299
+ // must not read "crm" as a positional. Boolean flags (--studio) have no
2300
+ // value and are skipped alone.
2301
+ function dataPositionals() {
2302
+ const out = [];
2303
+ for (let i = 0; i < rest.length; i++) {
2304
+ const a = rest[i];
2305
+ if (a.startsWith("--")) {
2306
+ if (DATA_VALUE_FLAGS.has(a.slice(2))) i++;
2307
+ continue;
2308
+ }
2309
+ out.push(a);
2310
+ }
2311
+ return out;
2312
+ }
2313
+
2314
+ function parseJsonFlag(name) {
2315
+ const raw = flag(name);
2316
+ if (raw === undefined) return undefined;
2317
+ try {
2318
+ return JSON.parse(raw);
2319
+ } catch {
2320
+ fail("BAD_JSON", `--${name} is not valid JSON. Quote the whole value in single quotes, e.g. --${name} '{"field":"value"}'. Got: ${raw.slice(0, 120)}`);
2321
+ }
2322
+ }
2323
+
2324
+ // Which records namespace a data verb targets. Slug from --app or the
2325
+ // surrounding app folder; existence is decided server-side (an unknown slug
2326
+ // simply has zero records — list makes that visible immediately).
2327
+ function resolveDataApp() {
2328
+ const explicit = flag("app");
2329
+ const root = findAppRoot(process.cwd());
2330
+ const slug = explicit ?? (root ? readSlugFromConfig(root) : null);
2331
+ if (!slug) {
2332
+ fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
2333
+ }
2334
+ return rest.includes("--studio") ? `${slug}#dev` : slug;
2335
+ }
2336
+
2337
+ // mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
2338
+ // in 5 minutes — minted fresh per invocation, never stored.
2339
+ async function dataAuth() {
2340
+ const { host, key } = loadConfig();
2341
+ if (!key) fail("NOT_LOGGED_IN", `Reading and writing app data needs your workspace (${host}). Run \`monty login\` first.`);
2342
+ let convexUrl = null;
2343
+ try {
2344
+ convexUrl = (await fetch(`${host}/api/config`).then((r) => r.json()))?.convexUrl;
2345
+ } catch { /* handled below */ }
2346
+ if (!convexUrl) fail("HOST_UNREACHABLE", `Could not read ${host}/api/config — is the host up and the network reachable?`);
2347
+ const r = await fetch(`${host}/api/dev-token`, {
2348
+ method: "POST",
2349
+ headers: { authorization: `Bearer ${key}` },
2350
+ });
2351
+ const body = await r.json().catch(() => null);
2352
+ if (!r.ok || !body?.token) {
2353
+ fail(body?.code ?? `HTTP_${r.status}`, body?.fix ?? "Minting a workspace token failed. Run `monty login`, then retry.");
2354
+ }
2355
+ return { convexUrl, token: body.token };
2356
+ }
2357
+
2358
+ // One records function over Convex's public HTTP API (plain-JSON format —
2359
+ // app data is JSON by construction, no convex encoding needed). ConvexError
2360
+ // payloads carry { code, fix } and surface verbatim: errors stay
2361
+ // instructions in the terminal the agent is watching.
2362
+ async function callRecords(kind, fn, args, auth) {
2363
+ let body = null;
2364
+ try {
2365
+ const r = await fetch(`${auth.convexUrl}/api/${kind}`, {
2366
+ method: "POST",
2367
+ headers: { "content-type": "application/json", authorization: `Bearer ${auth.token}` },
2368
+ body: JSON.stringify({ path: `records:${fn}`, args, format: "json" }),
2369
+ });
2370
+ body = await r.json().catch(() => null);
2371
+ } catch { /* handled below */ }
2372
+ if (!body) fail("CONVEX_UNREACHABLE", `The data backend at ${auth.convexUrl} did not answer — check the network and retry.`);
2373
+ if (body.status !== "success") {
2374
+ const d = body.errorData;
2375
+ if (d && typeof d === "object" && d.code) fail(d.code, d.fix ?? body.errorMessage ?? "See the error code.");
2376
+ fail("CONVEX_ERROR", body.errorMessage ?? "Unknown data-layer error — retry; if it persists, report it.");
2377
+ }
2378
+ return body.value;
2379
+ }
2380
+
2381
+ function printJson(value) {
2382
+ console.log(JSON.stringify(value, null, 2));
2383
+ }
2384
+
2385
+ // The documented row shape everywhere on the platform (SDK hooks, server
2386
+ // functions) is FLATTENED: app fields at the top level + the four system
2387
+ // fields. Mirror it here — and don't echo workspaceId/appId/table back;
2388
+ // scope is the caller's own arguments, not row payload.
2389
+ function flattenRow(doc) {
2390
+ if (!doc) return null;
2391
+ return {
2392
+ ...doc.data,
2393
+ _id: doc._id,
2394
+ _creationTime: doc._creationTime,
2395
+ updatedAt: doc.updatedAt,
2396
+ createdBy: doc.createdBy,
2397
+ };
2398
+ }
2399
+
2400
+ function dataUsage() {
2401
+ console.log("usage: monty data <verb> [table] [flags] read/write an app's Live records (add --studio for the Studio sandbox)");
2402
+ console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
2403
+ console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
2404
+ console.log(" get <table> <id>");
2405
+ console.log(" insert <table> --data '<json|[json,…]>'");
2406
+ console.log(" update <table> <id> --data '<json>' [--unset field,field]");
2407
+ console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
2408
+ console.log(" remove <table> <id>");
2409
+ console.log("target: --app <slug> (or run inside the app folder); default is LIVE data — --studio targets the sandbox");
2410
+ process.exit(1);
2411
+ }
2412
+
2413
+ async function data() {
2414
+ const [verb, table, id] = dataPositionals();
2415
+
2416
+ if (verb === "schema") {
2417
+ // Shape comes from the LOCAL source checkout (compiled through the real
2418
+ // pipeline) — the same monty.config.ts that defines what the app stores.
2419
+ const explicit = flag("app");
2420
+ const root = explicit
2421
+ ? (listLocalApps().find((a) => a.slug === explicit || a.id === explicit)?.path ?? null)
2422
+ : findAppRoot(process.cwd());
2423
+ if (!root) {
2424
+ fail("APP_NOT_LOCAL", explicit
2425
+ ? `No local source for "${explicit}" on this machine — run \`monty pull ${explicit}\` first, or cd into the app folder.`
2426
+ : "Not inside an app folder. Pass --app <slug> (needs the source pulled locally) or cd into the app.");
2427
+ }
2428
+ let meta;
2429
+ try {
2430
+ meta = await compileAppConfig(root);
2431
+ } catch (e) {
2432
+ if (e instanceof CompileError) fail(e.code, e.fix);
2433
+ throw e;
2434
+ }
2435
+ const tables = meta.schemaJson?.tables ?? {};
2436
+ if (table !== undefined) {
2437
+ if (!tables[table]) {
2438
+ fail("NO_SUCH_TABLE", `App "${meta.slug}" has no table "${table}". Tables: ${Object.keys(tables).join(", ") || "(none)"}.`);
2439
+ }
2440
+ printJson({ app: meta.slug, table, schema: tables[table] });
2441
+ } else {
2442
+ printJson({ app: meta.slug, tables });
2443
+ }
2444
+ return;
2445
+ }
2446
+
2447
+ const VERBS = new Set(["list", "get", "insert", "update", "upsert", "remove"]);
2448
+ if (!verb || !VERBS.has(verb)) dataUsage();
2449
+ if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
2450
+
2451
+ const app = resolveDataApp();
2452
+ const auth = await dataAuth();
2453
+
2454
+ switch (verb) {
2455
+ case "list": {
2456
+ const filter = parseJsonFlag("filter");
2457
+ const order = flag("order");
2458
+ if (order !== undefined && order !== "asc" && order !== "desc") {
2459
+ fail("BAD_ORDER", `--order must be "asc" or "desc" (default desc, newest first). Got: ${order}`);
2460
+ }
2461
+ const limit = Math.min(Math.max(parseInt(flag("limit") ?? "100", 10) || 100, 1), 1024);
2462
+ const value = await callRecords("query", "list", {
2463
+ app,
2464
+ table,
2465
+ ...(filter !== undefined ? { filter } : {}),
2466
+ ...(order !== undefined ? { order } : {}),
2467
+ paginationOpts: { numItems: limit, cursor: flag("cursor") ?? null },
2468
+ }, auth);
2469
+ printJson({
2470
+ rows: value.page.map(flattenRow),
2471
+ count: value.page.length,
2472
+ // A non-null cursor means MORE rows exist: repeat with --cursor <c>.
2473
+ cursor: value.isDone ? null : value.continueCursor,
2474
+ });
2475
+ return;
2476
+ }
2477
+ case "get": {
2478
+ if (!id) fail("MISSING_ID", "Usage: monty data get <table> <id> — ids come from list on the same table.");
2479
+ printJson(flattenRow(await callRecords("query", "get", { app, table, id }, auth)));
2480
+ return;
2481
+ }
2482
+ case "insert": {
2483
+ const input = parseJsonFlag("data");
2484
+ if (input === undefined) fail("MISSING_DATA", `Usage: monty data insert ${table} --data '{"field":"value"}' — an array inserts each element.`);
2485
+ const rows = Array.isArray(input) ? input : [input];
2486
+ const ids = [];
2487
+ for (const row of rows) {
2488
+ ids.push(await callRecords("mutation", "insert", { app, table, data: row }, auth));
2489
+ }
2490
+ printJson(Array.isArray(input) ? { ids } : { id: ids[0] });
2491
+ return;
2492
+ }
2493
+ case "update": {
2494
+ if (!id) fail("MISSING_ID", "Usage: monty data update <table> <id> --data '{…}' — ids come from list/get on the same table.");
2495
+ const patch = parseJsonFlag("data");
2496
+ if (patch === undefined) fail("MISSING_DATA", `Usage: monty data update ${table} ${id} --data '{"field":"newValue"}' (shallow-merged; --unset a,b removes fields).`);
2497
+ const unsetRaw = flag("unset");
2498
+ await callRecords("mutation", "update", {
2499
+ app,
2500
+ table,
2501
+ id,
2502
+ data: patch,
2503
+ ...(unsetRaw ? { unset: unsetRaw.split(",").map((s) => s.trim()).filter(Boolean) } : {}),
2504
+ }, auth);
2505
+ printJson({ ok: true, id });
2506
+ return;
2507
+ }
2508
+ case "upsert": {
2509
+ // --key names the FIELDS that identify a row (e.g. --key linkedinUrl);
2510
+ // values come from each data row, so one flag serves single and batch
2511
+ // writes alike — and re-running an import never duplicates.
2512
+ const keyFields = (flag("key") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2513
+ if (keyFields.length === 0) {
2514
+ fail("MISSING_KEY", `Usage: monty data upsert ${table} --key <field[,field]> --data '{…}' — key fields identify the row to match on.`);
2515
+ }
2516
+ const input = parseJsonFlag("data");
2517
+ if (input === undefined) fail("MISSING_DATA", `Usage: monty data upsert ${table} --key ${keyFields.join(",")} --data '{"${keyFields[0]}":"…", …}' — an array upserts each element.`);
2518
+ const rows = Array.isArray(input) ? input : [input];
2519
+ const ids = [];
2520
+ for (const row of rows) {
2521
+ const key = {};
2522
+ for (const f of keyFields) {
2523
+ if (row[f] === undefined) {
2524
+ fail("MISSING_KEY_FIELD", `A data row is missing key field "${f}" — every row must carry its own key values. Row: ${JSON.stringify(row).slice(0, 120)}`);
2525
+ }
2526
+ key[f] = row[f];
2527
+ }
2528
+ // patch = the caller's fields (merged over an existing row); full =
2529
+ // the stored row on insert. The CLI applies no zod defaults, so they
2530
+ // are the same object here — `monty data schema` shows what a
2531
+ // complete row needs.
2532
+ ids.push(await callRecords("mutation", "upsert", { app, table, key, patch: row, full: row }, auth));
2533
+ }
2534
+ printJson(Array.isArray(input) ? { ids } : { id: ids[0] });
2535
+ return;
2536
+ }
2537
+ case "remove": {
2538
+ if (!id) fail("MISSING_ID", "Usage: monty data remove <table> <id> — ids come from list/get on the same table.");
2539
+ await callRecords("mutation", "remove", { app, table, id }, auth);
2540
+ printJson({ ok: true, id, removed: true });
2541
+ return;
2542
+ }
2543
+ }
2544
+ }
2545
+
1183
2546
  // ── dispatch ───────────────────────────────────────────────────────────────
1184
2547
  // Keep agent skills fresh on every invocation (user level + current app).
1185
- installSkills({ appDir: findAppRoot(process.cwd()) });
2548
+ // `dev` and `logs` skip the refresh here: attach and log reads must stay
2549
+ // fast (a skills refresh can shell out to npx for two minutes) — dev's
2550
+ // fresh-start path installs skills itself once it owns the session.
2551
+ if (command !== "dev" && command !== "logs") {
2552
+ installSkills({ appDir: findAppRoot(process.cwd()) });
2553
+ }
1186
2554
 
1187
2555
  switch (command) {
1188
2556
  case "login":
@@ -1191,9 +2559,22 @@ switch (command) {
1191
2559
  case "create":
1192
2560
  await create();
1193
2561
  break;
2562
+ case "pull":
2563
+ await pull();
2564
+ break;
2565
+ case "commit":
2566
+ await commit();
2567
+ break;
2568
+ case "log":
2569
+ case "versions":
2570
+ await versionsLog();
2571
+ break;
1194
2572
  case "dev":
1195
2573
  await dev();
1196
2574
  break;
2575
+ case "logs":
2576
+ await logs();
2577
+ break;
1197
2578
  case "add":
1198
2579
  await add();
1199
2580
  break;
@@ -1229,24 +2610,32 @@ switch (command) {
1229
2610
  case "deploy":
1230
2611
  await deploy();
1231
2612
  break;
2613
+ case "data":
2614
+ await data();
2615
+ break;
1232
2616
  case "secret":
1233
2617
  await secret();
1234
2618
  break;
1235
2619
  default:
1236
- console.log("usage: monty <login|create|current|select|apps|install|dev|build|typecheck|add|components|docs|deploy|skills>");
2620
+ console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|data|skills>");
1237
2621
  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)");
2622
+ console.log(" create <slug> [--name N] [--icon I] [--build ID] register + stamp a new app into ~/.monty/apps/<id> (needs login)");
2623
+ console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
2624
+ console.log(" commit [-m \"message\"] version the app's source in the workspace without publishing");
2625
+ console.log(" log [slug] the app's source version history (commits + publishes)");
2626
+ console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app in Studio, or attach to a running session (sandboxed data)");
2627
+ console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, publish results)");
1240
2628
  console.log(" add <name...> install curated UI components (see `monty components`)");
1241
2629
  console.log(" components [query] list the curated component catalog");
1242
2630
  console.log(" docs <name> view a component's source before installing");
1243
2631
  console.log(" current which app folder am I in?");
1244
2632
  console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
1245
- console.log(" apps list local apps in ~/Monty");
2633
+ console.log(" apps list local apps (~/.monty/apps + legacy ~/Monty)");
1246
2634
  console.log(" install install app dependencies");
1247
2635
  console.log(" build production build (vite, via monty)");
1248
2636
  console.log(" typecheck typecheck (builds first if needed)");
1249
2637
  console.log(" deploy build + upload this app straight to Live");
2638
+ console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
1250
2639
  console.log(" skills install/refresh the agent build skill");
1251
2640
  process.exit(command ? 1 : 0);
1252
2641
  }