@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 +1446 -57
- package/package.json +1 -1
- package/skills/monty-build/SKILL.md +32 -18
- package/template/.claude/settings.json +5 -0
- package/template/AGENTS.md +59 -2
- package/template/gitignore +5 -0
- package/template/index.html +13 -2
- package/template/monty.config.ts +14 -13
- package/template/package.json +1 -1
- package/template/src/main.tsx +4 -2
- package/template/src/routes/index.tsx +20 -245
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
|
-
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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}"
|
|
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
|
-
|
|
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,
|
|
649
|
+
: join(MONTY_HOME, appId);
|
|
358
650
|
if (existsSync(target)) {
|
|
359
|
-
fail("DIR_EXISTS", `${target} already exists.
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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.
|
|
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(
|
|
1296
|
+
const child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
|
|
578
1297
|
cwd: appDir,
|
|
579
|
-
stdio: ["ignore", "pipe", "
|
|
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 ${
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1504
|
+
pubChild = spawn(process.execPath, [fileURLToPath(import.meta.url), "deploy", "--from-dev"], {
|
|
677
1505
|
cwd: appDir,
|
|
678
|
-
stdio: ["ignore", "
|
|
1506
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1507
|
+
});
|
|
1508
|
+
pubChild.stdout.on("data", (c) => {
|
|
1509
|
+
process.stdout.write(c);
|
|
1510
|
+
pubTee(c);
|
|
679
1511
|
});
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1239
|
-
console.log("
|
|
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
|
|
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
|
}
|