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