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