@montytools/cli 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/monty.mjs +591 -255
- package/lib/schemaCodegen.mjs +27 -1
- package/package.json +1 -1
- package/skills/monty-build/SKILL.md +47 -28
- package/skills/monty-design/SKILL.md +123 -0
- package/skills/monty-operate/SKILL.md +7 -9
- package/template/AGENTS.md +50 -19
- package/template/package.json +1 -1
- package/template/src/index.css +35 -31
package/bin/monty.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// monty — the Monty platform CLI. Output is agent-shaped: structured
|
|
3
3
|
// single-line events, no spinners, instruction-shaped errors, and a
|
|
4
|
-
// deterministic final line (`
|
|
4
|
+
// deterministic final line (`saved: …` / `error: …`).
|
|
5
5
|
|
|
6
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -63,10 +63,30 @@ function flag(name) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function fail(code, fix) {
|
|
66
|
+
// A failing save narrates itself to the desktop chip before exiting.
|
|
67
|
+
saveFailNote?.(code, fix);
|
|
68
|
+
saveFailNote = null;
|
|
66
69
|
console.error(`error: [MontyError ${code}] Fix: ${fix}`);
|
|
67
70
|
process.exit(1);
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// Set by deploy() while a save runs; fail() and the exit hook route the
|
|
74
|
+
// error instruction into .monty/save.json so the chip shows it.
|
|
75
|
+
let saveFailNote = null;
|
|
76
|
+
|
|
77
|
+
// The save state file the desktop tails: .monty/save.json narrates every
|
|
78
|
+
// save — agent-run saves in their own terminal included — so the chip can
|
|
79
|
+
// show Saving…/Saved/"Couldn't save" without parsing any output.
|
|
80
|
+
function writeSaveJson(appDir, state) {
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(join(appDir, ".monty"), { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(appDir, ".monty", "save.json"),
|
|
85
|
+
JSON.stringify({ ...state, at: Date.now() }, null, 2) + "\n",
|
|
86
|
+
);
|
|
87
|
+
} catch { /* advisory — the chip just misses this save */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
// ── Profiles & the .montyrc directory pin ──────────────────────────────────
|
|
71
91
|
// One key PER HOST (like kubectl contexts): logging into the local platform
|
|
72
92
|
// host never clobbers the prod key. Which host a command targets resolves,
|
|
@@ -257,6 +277,22 @@ function readMarker(path) {
|
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
|
|
280
|
+
// True when the installed marker is BEHIND this CLI. Ordering matters: two
|
|
281
|
+
// CLI versions share one machine (repo link vs npm global, bundle vs
|
|
282
|
+
// global), and an equality check makes them overwrite each other's skills
|
|
283
|
+
// on every alternating run — the older one must never win.
|
|
284
|
+
function markerOutdated(marker) {
|
|
285
|
+
if (!marker) return true;
|
|
286
|
+
const a = marker.split(".").map(Number);
|
|
287
|
+
const b = CLI_VERSION.split(".").map(Number);
|
|
288
|
+
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return marker !== CLI_VERSION;
|
|
289
|
+
for (let i = 0; i < 3; i++) {
|
|
290
|
+
if ((a[i] || 0) < (b[i] || 0)) return true;
|
|
291
|
+
if ((a[i] || 0) > (b[i] || 0)) return false;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
260
296
|
function skillsCliAdd({ global = false, cwd = undefined } = {}) {
|
|
261
297
|
const args = ["-y", "skills", "add", SKILLS_SRC, "-y", "--copy", ...(global ? ["-g"] : []), ...SKILL_AGENTS];
|
|
262
298
|
const res = spawnSync("npx", args, { cwd, stdio: "ignore", timeout: 120_000 });
|
|
@@ -285,7 +321,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
285
321
|
if (process.env.MONTY_NO_SKILLS) return;
|
|
286
322
|
try {
|
|
287
323
|
let changed = false;
|
|
288
|
-
if (force || readMarker(GLOBAL_SKILLS_MARKER)
|
|
324
|
+
if (force || markerOutdated(readMarker(GLOBAL_SKILLS_MARKER))) {
|
|
289
325
|
if (!skillsCliAdd({ global: true })) manualInstall(null);
|
|
290
326
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
291
327
|
writeFileSync(GLOBAL_SKILLS_MARKER, CLI_VERSION + "\n");
|
|
@@ -293,7 +329,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
293
329
|
}
|
|
294
330
|
if (appDir) {
|
|
295
331
|
const marker = join(appDir, ".agents", "skills", "monty-build", "VERSION");
|
|
296
|
-
if (force || readMarker(marker)
|
|
332
|
+
if (force || markerOutdated(readMarker(marker))) {
|
|
297
333
|
if (!skillsCliAdd({ cwd: appDir })) manualInstall(appDir);
|
|
298
334
|
mkdirSync(dirname(marker), { recursive: true });
|
|
299
335
|
writeFileSync(marker, CLI_VERSION + "\n");
|
|
@@ -310,17 +346,42 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
310
346
|
// ── monty current / select / apps ───────────────────────────────────────────
|
|
311
347
|
// Folder management users never think about: every app lives in ~/Monty,
|
|
312
348
|
// `current` says where you are, `select` prints the folder for cd $(...).
|
|
349
|
+
// The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
|
|
350
|
+
// create and pull) or, for older folders, monty.config.ts.
|
|
351
|
+
function isAppRoot(dir) {
|
|
352
|
+
return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
|
|
353
|
+
}
|
|
354
|
+
|
|
313
355
|
function findAppRoot(start) {
|
|
314
356
|
let d = start;
|
|
315
357
|
for (;;) {
|
|
316
|
-
if (
|
|
358
|
+
if (isAppRoot(d)) return d;
|
|
317
359
|
const parent = dirname(d);
|
|
318
360
|
if (parent === d) return null;
|
|
319
361
|
d = parent;
|
|
320
362
|
}
|
|
321
363
|
}
|
|
322
364
|
|
|
365
|
+
// The identity stamp: { id, slug, name, icon, registryOwned? } — the app's
|
|
366
|
+
// durable identity on this machine, independent of the config file.
|
|
367
|
+
function readAppJson(dir) {
|
|
368
|
+
try {
|
|
369
|
+
return JSON.parse(readFileSync(join(dir, ".monty", "app.json"), "utf8"));
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function writeAppJson(dir, patch) {
|
|
376
|
+
mkdirSync(join(dir, ".monty"), { recursive: true });
|
|
377
|
+
const next = { ...(readAppJson(dir) ?? {}), ...patch };
|
|
378
|
+
writeFileSync(join(dir, ".monty", "app.json"), JSON.stringify(next, null, 2) + "\n");
|
|
379
|
+
return next;
|
|
380
|
+
}
|
|
381
|
+
|
|
323
382
|
function readSlugFromConfig(dir) {
|
|
383
|
+
const stamped = readAppJson(dir)?.slug;
|
|
384
|
+
if (typeof stamped === "string" && stamped) return stamped;
|
|
324
385
|
try {
|
|
325
386
|
return /slug:\s*"([^"]+)"/.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
326
387
|
} catch {
|
|
@@ -329,6 +390,8 @@ function readSlugFromConfig(dir) {
|
|
|
329
390
|
}
|
|
330
391
|
|
|
331
392
|
function readIdFromConfig(dir) {
|
|
393
|
+
const stamped = readAppJson(dir)?.id;
|
|
394
|
+
if (typeof stamped === "string" && stamped) return stamped;
|
|
332
395
|
try {
|
|
333
396
|
return /^[ \t]*id:\s*"([^"]+)"/m.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
334
397
|
} catch {
|
|
@@ -340,7 +403,7 @@ function scanAppsHome(root) {
|
|
|
340
403
|
if (!existsSync(root)) return [];
|
|
341
404
|
return readdirSync(root)
|
|
342
405
|
.map((name) => join(root, name))
|
|
343
|
-
.filter((p) =>
|
|
406
|
+
.filter((p) => isAppRoot(p))
|
|
344
407
|
.map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
|
|
345
408
|
}
|
|
346
409
|
|
|
@@ -391,11 +454,10 @@ function apps() {
|
|
|
391
454
|
}
|
|
392
455
|
|
|
393
456
|
// Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
|
|
394
|
-
// tar.gz buffer + its sha256 — the snapshot unit `monty
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
// the 10MB cap.
|
|
457
|
+
// tar.gz buffer + its sha256 — the snapshot unit every `monty save`
|
|
458
|
+
// uploads. gzip runs with -n (no embedded timestamp) so an UNCHANGED tree
|
|
459
|
+
// packs to identical bytes — the hash doubles as the change detector.
|
|
460
|
+
// Returns null when packing fails; { tooLarge } past the 10MB cap.
|
|
399
461
|
function packSource(appDir) {
|
|
400
462
|
mkdirSync(join(appDir, ".monty"), { recursive: true });
|
|
401
463
|
const srcTar = join(appDir, ".monty", "source-upload.tar.gz");
|
|
@@ -417,60 +479,12 @@ function packSource(appDir) {
|
|
|
417
479
|
}
|
|
418
480
|
|
|
419
481
|
function readSlug(appDir) {
|
|
420
|
-
|
|
421
|
-
return /slug:\s*"([^"]+)"/.exec(readFileSync(join(appDir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
422
|
-
} catch {
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// ── monty commit ───────────────────────────────────────────────────────────
|
|
428
|
-
// Version the app's source WITHOUT publishing: pack the tree, upload it as
|
|
429
|
-
// one line of history. Git commit with everything stripped except "track
|
|
430
|
-
// versions" — no branches, no diffs, no local repo; history lives in the
|
|
431
|
-
// workspace and survives this folder.
|
|
432
|
-
async function commit() {
|
|
433
|
-
const appDir = requireAppDir("commit");
|
|
434
|
-
const slug = readSlug(appDir);
|
|
435
|
-
if (!slug) fail("NO_SLUG", "monty.config.ts has no slug — fix the config, then retry.");
|
|
436
|
-
const { host, key } = loadConfig();
|
|
437
|
-
if (!key) fail("NOT_LOGGED_IN", "Committing stores the snapshot in your workspace. Run `monty login` first.");
|
|
438
|
-
const mIdx = rest.indexOf("-m");
|
|
439
|
-
const message = flag("message") ?? (mIdx >= 0 ? rest[mIdx + 1] : undefined) ?? "checkpoint";
|
|
440
|
-
const packed = packSource(appDir);
|
|
441
|
-
if (packed === null) fail("PACK_FAILED", "Packing the source failed (tar error). Retry; check the folder is readable.");
|
|
442
|
-
if (packed.tooLarge) fail("SOURCE_TOO_LARGE", "The source tree exceeds 10 MB (node_modules/dist excluded). Remove large assets, then retry.");
|
|
443
|
-
try {
|
|
444
|
-
const stamp = JSON.parse(readFileSync(join(appDir, ".monty", "source.json"), "utf8"));
|
|
445
|
-
if (stamp.hash === packed.hash) {
|
|
446
|
-
console.log(`nothing to commit — source unchanged since ${packed.hash.slice(0, 7)}`);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
} catch {
|
|
450
|
-
/* no stamp yet — first commit from this folder */
|
|
451
|
-
}
|
|
452
|
-
const form = new FormData();
|
|
453
|
-
form.set("monty", JSON.stringify({ slug, hash: packed.hash, message }));
|
|
454
|
-
form.set("source", new Blob([packed.buf]), "source.tar.gz");
|
|
455
|
-
const res = await fetch(`${host}/api/source`, {
|
|
456
|
-
method: "POST",
|
|
457
|
-
headers: { authorization: `Bearer ${key}` },
|
|
458
|
-
body: form,
|
|
459
|
-
});
|
|
460
|
-
const body = await res.json().catch(() => null);
|
|
461
|
-
if (!res.ok || !body?.ok) {
|
|
462
|
-
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Uploading the snapshot failed — check the connection and retry.");
|
|
463
|
-
}
|
|
464
|
-
writeFileSync(
|
|
465
|
-
join(appDir, ".monty", "source.json"),
|
|
466
|
-
JSON.stringify({ hash: packed.hash, syncedAt: Date.now() }) + "\n",
|
|
467
|
-
);
|
|
468
|
-
console.log(`committed: ${packed.hash.slice(0, 7)} "${message}" (${(packed.buf.byteLength / 1024).toFixed(0)} KB)`);
|
|
482
|
+
return readSlugFromConfig(appDir);
|
|
469
483
|
}
|
|
470
484
|
|
|
471
485
|
// ── monty log ──────────────────────────────────────────────────────────────
|
|
472
|
-
// The app's version history, newest first
|
|
473
|
-
//
|
|
486
|
+
// The app's version history, newest first — one row per `monty save`.
|
|
487
|
+
// (Not `monty logs` — that tails the dev shell.)
|
|
474
488
|
async function versionsLog() {
|
|
475
489
|
const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
|
|
476
490
|
if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
|
|
@@ -484,19 +498,19 @@ async function versionsLog() {
|
|
|
484
498
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not list versions — check the connection and `monty login`.");
|
|
485
499
|
}
|
|
486
500
|
if (body.versions.length === 0) {
|
|
487
|
-
console.log(`no versions of "${slug}" yet — \`monty
|
|
501
|
+
console.log(`no versions of "${slug}" yet — \`monty save\` creates the first one.`);
|
|
488
502
|
return;
|
|
489
503
|
}
|
|
490
504
|
for (const v of body.versions) {
|
|
491
505
|
const when = new Date(v.createdAt).toISOString().slice(0, 16).replace("T", " ");
|
|
492
|
-
console.log(`${v.hash.slice(0, 7)} ${when} ${v.
|
|
506
|
+
console.log(`${v.hash.slice(0, 7)} ${when} ${v.message}`);
|
|
493
507
|
}
|
|
494
508
|
console.log(`\nrestore one: monty pull ${slug} --version <hash> [--force]`);
|
|
495
509
|
}
|
|
496
510
|
|
|
497
511
|
// ── monty pull ─────────────────────────────────────────────────────────────
|
|
498
|
-
// Restore an app's
|
|
499
|
-
// `monty
|
|
512
|
+
// Restore an app's saved source snapshot onto this machine. Every
|
|
513
|
+
// `monty save` uploads the source tree beside the bundle; pull is
|
|
500
514
|
// how a second machine (or one that lost the folder) gets the code back.
|
|
501
515
|
// Refuses to touch an existing folder without --force — it may hold
|
|
502
516
|
// unpublished work the snapshot would destroy.
|
|
@@ -541,11 +555,11 @@ async function pull() {
|
|
|
541
555
|
expectedHash = matches[0].hash;
|
|
542
556
|
downloadUrl = `${host}/api/source?slug=${slug}&hash=${expectedHash}`;
|
|
543
557
|
} else if (!app.sourceHash) {
|
|
544
|
-
fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each
|
|
558
|
+
fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each \`monty save\`. Run it once from the machine that has the source, then pull works everywhere.`);
|
|
545
559
|
}
|
|
546
560
|
const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
|
|
547
561
|
if (existsSync(target) && !rest.includes("--force")) {
|
|
548
|
-
fail("DIR_EXISTS", `${target} already exists and may hold
|
|
562
|
+
fail("DIR_EXISTS", `${target} already exists and may hold unsaved work. Compare it with the saved version first; re-run with --force to REPLACE it with the snapshot.`);
|
|
549
563
|
}
|
|
550
564
|
|
|
551
565
|
console.log(`pull: ${slug}${versionFlag ? ` @ ${expectedHash.slice(0, 7)}` : ""} <- ${host}`);
|
|
@@ -559,7 +573,7 @@ async function pull() {
|
|
|
559
573
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
560
574
|
const hash = createHash("sha256").update(buf).digest("hex");
|
|
561
575
|
if (hash !== expectedHash) {
|
|
562
|
-
fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists,
|
|
576
|
+
fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
|
|
563
577
|
}
|
|
564
578
|
|
|
565
579
|
// Extract into a staging folder, then move into place — a failed extract
|
|
@@ -573,7 +587,7 @@ async function pull() {
|
|
|
573
587
|
rmSync(tarFile, { force: true });
|
|
574
588
|
if (untar.status !== 0) {
|
|
575
589
|
rmSync(staging, { recursive: true, force: true });
|
|
576
|
-
fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists,
|
|
590
|
+
fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
|
|
577
591
|
}
|
|
578
592
|
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
579
593
|
renameSync(staging, target);
|
|
@@ -593,6 +607,14 @@ async function pull() {
|
|
|
593
607
|
join(target, ".monty", "source.json"),
|
|
594
608
|
JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
|
|
595
609
|
);
|
|
610
|
+
// Re-stamp identity (the snapshot excludes .monty/): the workspace row is
|
|
611
|
+
// the authority for id/name/icon.
|
|
612
|
+
writeAppJson(target, {
|
|
613
|
+
...(app.id ? { id: app.id } : {}),
|
|
614
|
+
slug,
|
|
615
|
+
...(app.name ? { name: app.name } : {}),
|
|
616
|
+
...(app.icon ? { icon: app.icon } : {}),
|
|
617
|
+
});
|
|
596
618
|
console.log(`pulled: ${target}`);
|
|
597
619
|
console.log("next: `monty install`, then `monty dev`.");
|
|
598
620
|
}
|
|
@@ -605,20 +627,56 @@ function isConfigOnlyApp(appDir) {
|
|
|
605
627
|
return !existsSync(join(appDir, "index.html"));
|
|
606
628
|
}
|
|
607
629
|
|
|
630
|
+
// Literal read of the config's `schedule` block ({ fn: "cron expr" }) —
|
|
631
|
+
// the same light-touch parse the SDK's vite plugin uses for publicFns.
|
|
632
|
+
// Registry-owned sessions read it this way so the cron ticker works
|
|
633
|
+
// without a config compile.
|
|
634
|
+
function readScheduleLiteral(appDir) {
|
|
635
|
+
try {
|
|
636
|
+
const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
|
|
637
|
+
const block = /schedule:\s*{([^}]*)}/m.exec(src)?.[1];
|
|
638
|
+
if (!block) return undefined;
|
|
639
|
+
const out = {};
|
|
640
|
+
for (const m of block.matchAll(/["']?([a-zA-Z][a-zA-Z0-9_]*)["']?\s*:\s*"([^"]+)"/g)) {
|
|
641
|
+
out[m[1]] = m[2];
|
|
642
|
+
}
|
|
643
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
644
|
+
} catch {
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The code half's custom pages, by file convention: every TOP-LEVEL route
|
|
650
|
+
// file in src/routes/ is a page (nested routes — dot-separated names — are a
|
|
651
|
+
// page's inner paths). `__root`/`index` are the SPA's own plumbing, never
|
|
652
|
+
// pages. Saves register these as fnsJson.pages; a running session serves
|
|
653
|
+
// every declared page directly.
|
|
654
|
+
function discoverPages(appDir) {
|
|
655
|
+
const routesDir = join(appDir, "src", "routes");
|
|
656
|
+
if (!existsSync(routesDir)) return [];
|
|
657
|
+
return readdirSync(routesDir)
|
|
658
|
+
.filter((f) => /\.(tsx|jsx)$/.test(f))
|
|
659
|
+
.map((f) => f.replace(/\.(tsx|jsx)$/, ""))
|
|
660
|
+
.filter((name) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name) && name !== "index")
|
|
661
|
+
.sort()
|
|
662
|
+
.map((name) => ({ name, path: `/${name}` }));
|
|
663
|
+
}
|
|
664
|
+
|
|
608
665
|
const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
|
|
609
666
|
|
|
610
|
-
The
|
|
611
|
-
(\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
|
|
612
|
-
|
|
667
|
+
The app is rendered by the Monty platform from its WORKSPACE manifest —
|
|
668
|
+
tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
|
|
669
|
+
\`settings\`, and \`pages\`. There is no src/, no React, no build.
|
|
613
670
|
|
|
614
|
-
-
|
|
615
|
-
|
|
671
|
+
- The schema lives in the workspace, and its door is the schema API:
|
|
672
|
+
read it with \`monty schema\`, change it with \`monty schema set <file|->\`
|
|
673
|
+
(validated, CAS-guarded, live within seconds). monty.config.ts edits do
|
|
674
|
+
NOT change a workspace-owned app's schema.
|
|
616
675
|
- Formulas are strings in the Monty expression grammar, e.g.
|
|
617
676
|
\`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
|
|
618
677
|
ABOVE the formula and \`metrics.<name>\` are in scope.
|
|
619
|
-
- \`monty
|
|
620
|
-
|
|
621
|
-
scaffold; the config keeps working unchanged.
|
|
678
|
+
- Need a bespoke page later? \`monty add page\` declares it and upgrades this
|
|
679
|
+
app with a SPA scaffold; \`monty save\` ships the code.
|
|
622
680
|
`;
|
|
623
681
|
|
|
624
682
|
function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
|
|
@@ -718,6 +776,9 @@ async function create() {
|
|
|
718
776
|
if (!rest.includes("--spa")) {
|
|
719
777
|
console.log(`create: ${slug} -> ${target} (config-only)`);
|
|
720
778
|
writeConfigOnlyScaffold(target, { appId, slug, name, icon });
|
|
779
|
+
// The identity stamp is the app-root marker and identity source from
|
|
780
|
+
// here on — the config file is just code.
|
|
781
|
+
writeAppJson(target, { id: appId, slug, name, icon });
|
|
721
782
|
// The user's brief lands at the top of AGENTS.md, same as SPA creates.
|
|
722
783
|
const brief = flag("description");
|
|
723
784
|
if (brief?.trim()) {
|
|
@@ -777,7 +838,7 @@ async function create() {
|
|
|
777
838
|
// hard-excludes the real name from tarballs); the in-repo template has the
|
|
778
839
|
// real file. Normalize, and backfill for bundles that carried neither —
|
|
779
840
|
// without a .gitignore, tailwind v4's content scan includes .monty/ and
|
|
780
|
-
// full-reloads
|
|
841
|
+
// full-reloads the session on every dev.json touch.
|
|
781
842
|
const gitignorePath = join(target, ".gitignore");
|
|
782
843
|
if (existsSync(join(target, "gitignore"))) {
|
|
783
844
|
renameSync(join(target, "gitignore"), gitignorePath);
|
|
@@ -805,6 +866,9 @@ async function create() {
|
|
|
805
866
|
htmlPath,
|
|
806
867
|
readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
|
|
807
868
|
);
|
|
869
|
+
// The identity stamp is the app-root marker and identity source from
|
|
870
|
+
// here on — the config file is just code the bundle imports.
|
|
871
|
+
writeAppJson(target, { id: appId, slug, name, icon });
|
|
808
872
|
|
|
809
873
|
// The user's brief (--description, e.g. from the desktop's create dialog)
|
|
810
874
|
// goes to the TOP of AGENTS.md — agent harnesses can't be handed an initial
|
|
@@ -920,7 +984,7 @@ async function freePort(start) {
|
|
|
920
984
|
// Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
|
|
921
985
|
// minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
|
|
922
986
|
// SDK's vite plugin) and upgrades the app automatically before dev/deploy.
|
|
923
|
-
const MIN_SDK = "0.2.
|
|
987
|
+
const MIN_SDK = "0.2.1";
|
|
924
988
|
const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
|
|
925
989
|
|
|
926
990
|
function installedSdkVersion(appDir) {
|
|
@@ -1200,12 +1264,12 @@ function printAttach(appDir, s) {
|
|
|
1200
1264
|
const beat = typeof s.lastHeartbeatAt === "number" ? Math.round((Date.now() - s.lastHeartbeatAt) / 1000) : null;
|
|
1201
1265
|
console.log(
|
|
1202
1266
|
beat !== null && beat > Math.round(DEV_JSON_STALE_MS / 1000)
|
|
1203
|
-
? `state: online (no heartbeat for ${beat}s —
|
|
1267
|
+
? `state: online (no heartbeat for ${beat}s — the workspace may show the session offline)`
|
|
1204
1268
|
: `state: online (heartbeat ${beat ?? "?"}s ago)`,
|
|
1205
1269
|
);
|
|
1206
|
-
if (s.
|
|
1270
|
+
if (s.host && s.slug) console.log(`app: ${s.host}/apps/${s.slug} — your app runs there while this is up`);
|
|
1207
1271
|
} else {
|
|
1208
|
-
console.log("state: ready (registering with the workspace — the
|
|
1272
|
+
console.log("state: ready (registering with the workspace — the app link appears on the first successful heartbeat)");
|
|
1209
1273
|
}
|
|
1210
1274
|
}
|
|
1211
1275
|
console.log("logs: `monty logs -f` follows output; `monty logs -n 100` shows recent lines");
|
|
@@ -1327,11 +1391,11 @@ async function sweepOrphans(s) {
|
|
|
1327
1391
|
}
|
|
1328
1392
|
|
|
1329
1393
|
// ── monty dev ──────────────────────────────────────────────────────────────
|
|
1330
|
-
//
|
|
1331
|
-
// as the app's
|
|
1332
|
-
// at usemonty.dev while it runs.
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1394
|
+
// Runs the app's session: vite locally + a Cloudflare quick tunnel
|
|
1395
|
+
// registered as the app's session channel, so workspace admins see the
|
|
1396
|
+
// app (HMR included) at usemonty.dev while it runs. One data namespace:
|
|
1397
|
+
// the session reads and writes the app's REAL records — editing is the
|
|
1398
|
+
// change going live. `monty save` ships code.
|
|
1335
1399
|
async function dev() {
|
|
1336
1400
|
const appDir = requireAppDir("dev");
|
|
1337
1401
|
|
|
@@ -1380,11 +1444,21 @@ async function dev() {
|
|
|
1380
1444
|
|
|
1381
1445
|
installSkills({ appDir });
|
|
1382
1446
|
ensureSdk(appDir);
|
|
1383
|
-
|
|
1447
|
+
// Registry-owned apps (stamped on the first configIgnored beat): the
|
|
1448
|
+
// platform reads NOTHING from the config compile — identity comes from
|
|
1449
|
+
// the stamp and the compile is skipped entirely. The config file is just
|
|
1450
|
+
// code the bundle imports; `schedule` (a code-door declaration the
|
|
1451
|
+
// session cron ticker needs) is read literally, the same way the vite
|
|
1452
|
+
// plugin reads `publicFns`.
|
|
1453
|
+
const stamp = readAppJson(appDir);
|
|
1454
|
+
const registryOwned = stamp?.registryOwned === true && typeof stamp?.slug === "string";
|
|
1455
|
+
const meta = registryOwned
|
|
1456
|
+
? { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) }
|
|
1457
|
+
: await compileConfig(appDir);
|
|
1384
1458
|
const cfg = loadConfig();
|
|
1385
1459
|
const host = cfg?.host ?? DEFAULT_HOST;
|
|
1386
1460
|
// CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
|
|
1387
|
-
// compile + push — the platform shell renders the
|
|
1461
|
+
// compile + push — the platform shell renders the app.
|
|
1388
1462
|
const configOnly = isConfigOnlyApp(appDir);
|
|
1389
1463
|
// Auto-pick a free port (agents run several apps side by side); an
|
|
1390
1464
|
// explicit --port is honored strictly.
|
|
@@ -1407,23 +1481,30 @@ async function dev() {
|
|
|
1407
1481
|
}
|
|
1408
1482
|
|
|
1409
1483
|
let tunnelChild = null;
|
|
1410
|
-
let pubChild = null;
|
|
1411
1484
|
let hbTimer = null;
|
|
1412
1485
|
let touchTimer = null;
|
|
1413
1486
|
let cronTimer = null;
|
|
1414
|
-
let publishing = false;
|
|
1415
1487
|
let ended = false;
|
|
1416
1488
|
let registeredOnce = false;
|
|
1489
|
+
// Held-config warnings print once per drift episode, not every beat.
|
|
1490
|
+
let driftAnnounced = false;
|
|
1491
|
+
// The registry-owned notice prints once per session.
|
|
1492
|
+
let configIgnoredAnnounced = false;
|
|
1417
1493
|
const devStartedAt = Date.now();
|
|
1418
1494
|
const sessionId = `dev_${randomBytes(16).toString("hex")}`;
|
|
1419
1495
|
const buildFile = join(appDir, ".monty", "build");
|
|
1420
1496
|
const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
|
|
1421
|
-
// The
|
|
1422
|
-
// edits to monty.config.ts are re-compiled (softly)
|
|
1423
|
-
// the platform within one heartbeat.
|
|
1497
|
+
// The session schema channel (manifest-less apps): heartbeats carry the
|
|
1498
|
+
// compiled schema, and edits to monty.config.ts are re-compiled (softly)
|
|
1499
|
+
// so schema changes reach the platform within one heartbeat.
|
|
1500
|
+
// Registry-owned apps skip all of it — their schema lives behind the
|
|
1501
|
+
// doors, and the local config copy follows the registry (auto-pull below).
|
|
1424
1502
|
let currentMeta = meta;
|
|
1503
|
+
// Once per registry change: the manifest hash we last tried to sync the
|
|
1504
|
+
// local config copy to (successful or refused — never loop on dirty).
|
|
1505
|
+
let syncAttemptedHash = null;
|
|
1425
1506
|
const configPath = join(appDir, "monty.config.ts");
|
|
1426
|
-
let configMtime = statSync(configPath).mtimeMs;
|
|
1507
|
+
let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
|
|
1427
1508
|
|
|
1428
1509
|
// Advertise this session. The touch timer (not the platform heartbeat,
|
|
1429
1510
|
// which starts minutes late or never when logged out) keeps updatedAt
|
|
@@ -1445,9 +1526,9 @@ async function dev() {
|
|
|
1445
1526
|
loggedIn,
|
|
1446
1527
|
appUrl: configOnly ? null : `http://localhost:${port}`,
|
|
1447
1528
|
tunnelUrl: null,
|
|
1448
|
-
|
|
1529
|
+
// Field names are wire contract (the desktop reads them).
|
|
1449
1530
|
previewUrl: loggedIn && !configOnly
|
|
1450
|
-
? `${host}/
|
|
1531
|
+
? `${host}/apps/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
|
|
1451
1532
|
: null,
|
|
1452
1533
|
publishing: false,
|
|
1453
1534
|
lastHeartbeatAt: null,
|
|
@@ -1456,7 +1537,7 @@ async function dev() {
|
|
|
1456
1537
|
});
|
|
1457
1538
|
touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
|
|
1458
1539
|
|
|
1459
|
-
// The
|
|
1540
|
+
// The session cron runner: the Live counterpart is a real Cloudflare Cron
|
|
1460
1541
|
// Trigger on the app's fn-worker; here the CLI matches monty.config.ts
|
|
1461
1542
|
// `schedule` entries against the UTC clock once per minute and invokes the
|
|
1462
1543
|
// fn through the same /__monty/fn runtime (x-monty-schedule marks the
|
|
@@ -1492,6 +1573,7 @@ async function dev() {
|
|
|
1492
1573
|
cronTimer = setInterval(cronTick, 20_000);
|
|
1493
1574
|
|
|
1494
1575
|
async function refreshSchemaIfChanged() {
|
|
1576
|
+
if (registryOwned) return; // the doors own the schema; nothing to push
|
|
1495
1577
|
try {
|
|
1496
1578
|
const m = statSync(configPath).mtimeMs;
|
|
1497
1579
|
if (m === configMtime) return;
|
|
@@ -1500,7 +1582,7 @@ async function dev() {
|
|
|
1500
1582
|
if (fresh) {
|
|
1501
1583
|
currentMeta = fresh;
|
|
1502
1584
|
console.log(
|
|
1503
|
-
`schema: monty.config.ts changed —
|
|
1585
|
+
`schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
|
|
1504
1586
|
);
|
|
1505
1587
|
}
|
|
1506
1588
|
} catch { /* transient fs hiccup — next beat retries */ }
|
|
@@ -1525,7 +1607,6 @@ async function dev() {
|
|
|
1525
1607
|
if (hbTimer) clearInterval(hbTimer);
|
|
1526
1608
|
if (touchTimer) clearInterval(touchTimer);
|
|
1527
1609
|
if (cronTimer) clearInterval(cronTimer);
|
|
1528
|
-
try { pubChild?.kill(); } catch { /* already gone */ }
|
|
1529
1610
|
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
1530
1611
|
// vite is a direct child (no npx wrapper), so this actually kills it —
|
|
1531
1612
|
// a bare SIGTERM from the desktop must never orphan vite on the port.
|
|
@@ -1541,7 +1622,6 @@ async function dev() {
|
|
|
1541
1622
|
if (hbTimer) clearInterval(hbTimer);
|
|
1542
1623
|
if (touchTimer) clearInterval(touchTimer);
|
|
1543
1624
|
if (cronTimer) clearInterval(cronTimer);
|
|
1544
|
-
try { pubChild?.kill(); } catch { /* already gone */ }
|
|
1545
1625
|
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
1546
1626
|
try { child?.kill(); } catch { /* already gone */ }
|
|
1547
1627
|
console.log(`dev-session: superseded — ${fix}`);
|
|
@@ -1577,14 +1657,18 @@ async function dev() {
|
|
|
1577
1657
|
buildId,
|
|
1578
1658
|
schemaJson: currentMeta.schemaJson,
|
|
1579
1659
|
// App Manifest v2 (docs/manifest-v2.md) — present only for V2
|
|
1580
|
-
// configs;
|
|
1660
|
+
// configs; lands only for manifest-less apps (the doors own the
|
|
1661
|
+
// rest).
|
|
1581
1662
|
manifest: currentMeta.manifest,
|
|
1582
|
-
// The CAS base for the
|
|
1663
|
+
// The CAS base for the manifest-less landing (see `monty schema
|
|
1664
|
+
// pull`).
|
|
1583
1665
|
baseManifestHash:
|
|
1584
1666
|
currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
|
|
1585
1667
|
// The expose block rides the same compile as the schema — the dev
|
|
1586
|
-
// visitor preview is gated on it
|
|
1668
|
+
// visitor preview of a manifest-less app is gated on it.
|
|
1587
1669
|
exposure: currentMeta.exposure,
|
|
1670
|
+
// Rules ride the same channel (manifest-less apps only).
|
|
1671
|
+
rules: currentMeta.rules,
|
|
1588
1672
|
}),
|
|
1589
1673
|
signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
|
|
1590
1674
|
});
|
|
@@ -1608,9 +1692,17 @@ async function dev() {
|
|
|
1608
1692
|
}
|
|
1609
1693
|
return false;
|
|
1610
1694
|
}
|
|
1611
|
-
if (
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1695
|
+
if (data?.manifestDrift) {
|
|
1696
|
+
// The session registered, but the config push was HELD: the stored
|
|
1697
|
+
// schema changed since this checkout last synced (another editor).
|
|
1698
|
+
if (!driftAnnounced) {
|
|
1699
|
+
driftAnnounced = true;
|
|
1700
|
+
console.log(`schema drift (remote changes):${data.manifestDrift.summary ? `\n${data.manifestDrift.summary}` : ""}`);
|
|
1701
|
+
console.log(`config push held: ${data.manifestDrift.fix ?? "Run `monty schema pull`, merge, then save again."}`);
|
|
1702
|
+
}
|
|
1703
|
+
} else if (currentMeta.manifest !== undefined) {
|
|
1704
|
+
driftAnnounced = false;
|
|
1705
|
+
// This beat's manifest landed LIVE — the new CAS base.
|
|
1614
1706
|
try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
|
|
1615
1707
|
}
|
|
1616
1708
|
if (!registeredOnce) {
|
|
@@ -1619,37 +1711,58 @@ async function dev() {
|
|
|
1619
1711
|
} else {
|
|
1620
1712
|
sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1621
1713
|
}
|
|
1622
|
-
if (data?.
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
process.stdout.write(c);
|
|
1634
|
-
pubTee(c);
|
|
1635
|
-
});
|
|
1636
|
-
pubChild.stderr.on("data", (c) => {
|
|
1637
|
-
process.stderr.write(c);
|
|
1638
|
-
pubTee(c);
|
|
1714
|
+
if (data?.configIgnored && !configIgnoredAnnounced) {
|
|
1715
|
+
configIgnoredAnnounced = true;
|
|
1716
|
+
console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set <file>`.");
|
|
1717
|
+
// Stamp registry ownership: from the next session on, the config
|
|
1718
|
+
// compile is skipped entirely.
|
|
1719
|
+
try {
|
|
1720
|
+
writeAppJson(appDir, {
|
|
1721
|
+
registryOwned: true,
|
|
1722
|
+
slug: meta.slug,
|
|
1723
|
+
...(currentMeta.name ? { name: currentMeta.name } : {}),
|
|
1724
|
+
...(currentMeta.icon ? { icon: currentMeta.icon } : {}),
|
|
1639
1725
|
});
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1726
|
+
} catch { /* the stamp is a convenience; the beat decided */ }
|
|
1727
|
+
}
|
|
1728
|
+
// The local config copy follows the registry: when the stored manifest
|
|
1729
|
+
// moved (another editor, the schema door) and the local file is clean,
|
|
1730
|
+
// regenerate it in place — the sync of the mechanical copy is
|
|
1731
|
+
// automatic. Dirty or uncompilable files are left alone with the fix.
|
|
1732
|
+
if (
|
|
1733
|
+
typeof data?.manifestHash === "string" &&
|
|
1734
|
+
existsSync(configPath) &&
|
|
1735
|
+
data.manifestHash !== readSchemaState(appDir)?.hash &&
|
|
1736
|
+
data.manifestHash !== syncAttemptedHash
|
|
1737
|
+
) {
|
|
1738
|
+
syncAttemptedHash = data.manifestHash;
|
|
1739
|
+
try {
|
|
1740
|
+
// Already in sync (fresh checkout, no state file yet)? Stamp the
|
|
1741
|
+
// base and leave the file alone — regenerate only on real drift.
|
|
1742
|
+
let alreadySynced = false;
|
|
1743
|
+
try {
|
|
1744
|
+
const compiled = await compileAppConfig(appDir);
|
|
1745
|
+
if (compiled.manifest && manifestHash(compiled.manifest) === data.manifestHash) {
|
|
1746
|
+
writeSchemaState(appDir, data.manifestHash);
|
|
1747
|
+
alreadySynced = true;
|
|
1748
|
+
}
|
|
1749
|
+
} catch { /* uncompilable — let schemaPull's dirty check narrate */ }
|
|
1750
|
+
if (!alreadySynced) {
|
|
1751
|
+
await schemaPull({
|
|
1752
|
+
appDir,
|
|
1753
|
+
host,
|
|
1754
|
+
key: loadConfig()?.key ?? cfg.key,
|
|
1755
|
+
slug: meta.slug,
|
|
1756
|
+
force: false,
|
|
1757
|
+
compileAppConfig,
|
|
1758
|
+
fail: (code, fix) => {
|
|
1759
|
+
throw new Error(`${code} — ${fix}`);
|
|
1760
|
+
},
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
} catch (e) {
|
|
1764
|
+
console.log(`schema: the workspace manifest changed but the local copy was NOT regenerated (${String(e?.message ?? e).slice(0, 240)})`);
|
|
1765
|
+
}
|
|
1653
1766
|
}
|
|
1654
1767
|
return true;
|
|
1655
1768
|
} catch {
|
|
@@ -1660,7 +1773,7 @@ async function dev() {
|
|
|
1660
1773
|
|
|
1661
1774
|
async function startDevSession() {
|
|
1662
1775
|
if (!cfg?.key) {
|
|
1663
|
-
console.log("dev: not logged in — workspace
|
|
1776
|
+
console.log("dev: not logged in — workspace session disabled (run `monty login`)");
|
|
1664
1777
|
return;
|
|
1665
1778
|
}
|
|
1666
1779
|
await clearDevSession();
|
|
@@ -1682,17 +1795,17 @@ async function dev() {
|
|
|
1682
1795
|
if (ended || version !== tunnelVersion) return "superseded";
|
|
1683
1796
|
if (!dnsLive) {
|
|
1684
1797
|
if (initial) {
|
|
1685
|
-
console.log("tunnel: DNS never propagated —
|
|
1798
|
+
console.log("tunnel: DNS never propagated — session registered on localhost (visible on this machine's browser only)");
|
|
1686
1799
|
} else {
|
|
1687
|
-
console.log("tunnel: DNS never propagated for the new URL — keeping
|
|
1800
|
+
console.log("tunnel: DNS never propagated for the new URL — keeping the session offline until the next tunnel URL");
|
|
1688
1801
|
}
|
|
1689
1802
|
return "failed";
|
|
1690
1803
|
}
|
|
1691
1804
|
originUrl = url;
|
|
1692
1805
|
sf.write({ tunnelUrl: url });
|
|
1693
|
-
console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live;
|
|
1806
|
+
console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; session URL updated");
|
|
1694
1807
|
if (!initial && !(await heartbeat(originUrl))) {
|
|
1695
|
-
console.log("dev-session:
|
|
1808
|
+
console.log("dev-session: the session still has no registered tunnel; the next heartbeat will retry");
|
|
1696
1809
|
}
|
|
1697
1810
|
return "activated";
|
|
1698
1811
|
}
|
|
@@ -1716,34 +1829,40 @@ async function dev() {
|
|
|
1716
1829
|
console.log(`tunnel: ${t.url}`);
|
|
1717
1830
|
await activateTunnelUrl(t.url, { initial: true });
|
|
1718
1831
|
} else {
|
|
1719
|
-
console.log("tunnel: unavailable —
|
|
1832
|
+
console.log("tunnel: unavailable — session registered on localhost (visible in this machine's browser only)");
|
|
1720
1833
|
}
|
|
1721
1834
|
}
|
|
1722
1835
|
const registered = await heartbeat(originUrl, { claim: true });
|
|
1723
1836
|
console.log(
|
|
1724
1837
|
registered
|
|
1725
|
-
? `
|
|
1726
|
-
: `
|
|
1838
|
+
? `preview: ${host}/apps/${meta.slug} — the app surface follows this session while it runs; \`monty save\` updates the cloud copy`
|
|
1839
|
+
: `preview: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
|
|
1727
1840
|
);
|
|
1728
1841
|
hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
|
|
1729
1842
|
}
|
|
1730
1843
|
|
|
1731
1844
|
if (configOnly) {
|
|
1732
1845
|
sf.write({ state: "ready" });
|
|
1733
|
-
console.log(
|
|
1734
|
-
|
|
1846
|
+
console.log(
|
|
1847
|
+
registryOwned
|
|
1848
|
+
? "ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`"
|
|
1849
|
+
: "ready: config-only — saves land LIVE; the workspace renders them within seconds",
|
|
1850
|
+
);
|
|
1735
1851
|
void startDevSession();
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1852
|
+
if (!registryOwned) {
|
|
1853
|
+
// Manifest-less apps only: config edits should land in seconds, not a
|
|
1854
|
+
// heartbeat — watch the mtime and trigger an early beat (which
|
|
1855
|
+
// recompiles + pushes). Registry-owned apps have nothing to push.
|
|
1856
|
+
const cfgWatch = setInterval(() => {
|
|
1857
|
+
try {
|
|
1858
|
+
if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
|
|
1859
|
+
} catch { /* transient fs hiccup */ }
|
|
1860
|
+
}, 2000);
|
|
1861
|
+
const stopWatch = () => clearInterval(cfgWatch);
|
|
1862
|
+
process.on("SIGINT", stopWatch);
|
|
1863
|
+
process.on("SIGTERM", stopWatch);
|
|
1864
|
+
process.on("SIGHUP", stopWatch);
|
|
1865
|
+
}
|
|
1747
1866
|
}
|
|
1748
1867
|
|
|
1749
1868
|
let announced = false;
|
|
@@ -1759,7 +1878,7 @@ async function dev() {
|
|
|
1759
1878
|
if (!announced && /localhost:\d+/.test(text.replace(ANSI_RE, ""))) {
|
|
1760
1879
|
announced = true;
|
|
1761
1880
|
sf.write({ state: "ready" });
|
|
1762
|
-
console.log(`data:
|
|
1881
|
+
console.log(`data: live — this session reads and writes the app\'s real records`);
|
|
1763
1882
|
console.log(`ready: http://localhost:${port}`);
|
|
1764
1883
|
void startDevSession();
|
|
1765
1884
|
}
|
|
@@ -1949,7 +2068,7 @@ async function waitForDns(hostname) {
|
|
|
1949
2068
|
|
|
1950
2069
|
// Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
|
|
1951
2070
|
// binary on first use). Resolves with the public URL, or null on failure —
|
|
1952
|
-
//
|
|
2071
|
+
// the session then falls back to localhost-only registration. onOutput receives
|
|
1953
2072
|
// every chunk (both fds) for the dev.log tee.
|
|
1954
2073
|
function startTunnel(port, onUrlChange, onOutput) {
|
|
1955
2074
|
return new Promise((resolve) => {
|
|
@@ -2027,10 +2146,55 @@ function resolveComponent(name) {
|
|
|
2027
2146
|
// ── monty add page <name> ──────────────────────────────────────────────────
|
|
2028
2147
|
// Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
|
|
2029
2148
|
// use (config-only apps gain src/ + vite from the template — their
|
|
2030
|
-
// monty.config.ts and AGENTS.md stay untouched),
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2149
|
+
// monty.config.ts and AGENTS.md stay untouched), declares
|
|
2150
|
+
// `pages.<name> = { kind: "custom", path: "/<name>" }`, and writes the page
|
|
2151
|
+
// route. DECLARE-FIRST: on a workspace-owned app the entry lands through
|
|
2152
|
+
// the schema door BEFORE the code exists — a save carrying an undeclared
|
|
2153
|
+
// route refuses (DEPLOY_UNDECLARED_PAGE). The Shopify model: system pages
|
|
2154
|
+
// stay shell-rendered; only this page is the app's own code.
|
|
2155
|
+
|
|
2156
|
+
// Declare the page through the schema door. Returns "declared" | "already"
|
|
2157
|
+
// (workspace-owned app) or "config" (manifest-less: the config file is
|
|
2158
|
+
// still that app's editor, the caller registers the entry there).
|
|
2159
|
+
async function declarePageThroughDoor(appDir, pageName) {
|
|
2160
|
+
const slug = readSlug(appDir);
|
|
2161
|
+
const { host, key } = loadConfig();
|
|
2162
|
+
if (!slug || !key) return "config";
|
|
2163
|
+
const read = await fetch(`${host}/api/schema?slug=${slug}`, {
|
|
2164
|
+
headers: { authorization: `Bearer ${key}` },
|
|
2165
|
+
}).catch(() => null);
|
|
2166
|
+
const readBody = await read?.json().catch(() => null);
|
|
2167
|
+
if (!read?.ok || !readBody?.ok || readBody.manifest === null) return "config";
|
|
2168
|
+
const manifest = readBody.manifest;
|
|
2169
|
+
const keyOf = (n) => n.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2170
|
+
const declared = Object.keys(manifest.pages ?? {}).find((n) => keyOf(n) === keyOf(pageName));
|
|
2171
|
+
if (declared) {
|
|
2172
|
+
const kind = manifest.pages[declared]?.kind;
|
|
2173
|
+
if (kind !== "custom") {
|
|
2174
|
+
fail("PAGE_KIND", `"${declared}" is a ${kind} page in the app's manifest — custom code can't replace it. Pick another page name.`);
|
|
2175
|
+
}
|
|
2176
|
+
return "already";
|
|
2177
|
+
}
|
|
2178
|
+
// An empty pages block means default view pages derive from the tables;
|
|
2179
|
+
// materialize them first so declaring one custom page can't hide the rest.
|
|
2180
|
+
const pages =
|
|
2181
|
+
manifest.pages && Object.keys(manifest.pages).length > 0
|
|
2182
|
+
? { ...manifest.pages }
|
|
2183
|
+
: Object.fromEntries(Object.keys(manifest.tables ?? {}).map((t) => [t, { kind: "view", table: t }]));
|
|
2184
|
+
pages[pageName] = { kind: "custom", path: `/${pageName}` };
|
|
2185
|
+
const res = await fetch(`${host}/api/schema`, {
|
|
2186
|
+
method: "POST",
|
|
2187
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
2188
|
+
body: JSON.stringify({ slug, manifest: { ...manifest, pages }, baseHash: readBody.hash }),
|
|
2189
|
+
});
|
|
2190
|
+
const body = await res.json().catch(() => null);
|
|
2191
|
+
if (!res.ok || !body?.ok) {
|
|
2192
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Declaring the page through the schema door failed — check the connection and retry.");
|
|
2193
|
+
}
|
|
2194
|
+
try { writeSchemaState(appDir, body.hash); } catch { /* state is advisory */ }
|
|
2195
|
+
return "declared";
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2034
2198
|
async function addPage(appDir, pageName) {
|
|
2035
2199
|
if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
|
|
2036
2200
|
fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
|
|
@@ -2048,6 +2212,15 @@ async function addPage(appDir, pageName) {
|
|
|
2048
2212
|
if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
|
|
2049
2213
|
console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
|
|
2050
2214
|
}
|
|
2215
|
+
// Declare BEFORE any code exists: if the door refuses, nothing to clean up.
|
|
2216
|
+
const declared = await declarePageThroughDoor(appDir, pageName);
|
|
2217
|
+
if (declared !== "config") {
|
|
2218
|
+
console.log(
|
|
2219
|
+
declared === "declared"
|
|
2220
|
+
? `declared: pages.${pageName} through the schema door — live in the workspace now`
|
|
2221
|
+
: `declared: pages.${pageName} already in the workspace manifest`,
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2051
2224
|
|
|
2052
2225
|
// First custom page on a config-only app: bring in the SPA scaffold.
|
|
2053
2226
|
if (isConfigOnlyApp(appDir)) {
|
|
@@ -2083,6 +2256,9 @@ async function addPage(appDir, pageName) {
|
|
|
2083
2256
|
pkg.scripts = { ...tplPkg.scripts, ...pkg.scripts };
|
|
2084
2257
|
pkg.dependencies = { ...tplPkg.dependencies, ...pkg.dependencies };
|
|
2085
2258
|
pkg.devDependencies = { ...tplPkg.devDependencies, ...pkg.devDependencies };
|
|
2259
|
+
// vite.config.ts imports the ESM-only sdk plugin — without the
|
|
2260
|
+
// template's module type the config loads as CJS and fails to resolve it.
|
|
2261
|
+
if (tplPkg.type && !pkg.type) pkg.type = tplPkg.type;
|
|
2086
2262
|
// The published template pins the sdk; a workspace app may carry
|
|
2087
2263
|
// workspace:* — the merge above keeps the app's existing pin either way.
|
|
2088
2264
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
@@ -2108,13 +2284,31 @@ async function addPage(appDir, pageName) {
|
|
|
2108
2284
|
if (existsSync(starter)) rmSync(starter);
|
|
2109
2285
|
appendFileSync(
|
|
2110
2286
|
join(appDir, "AGENTS.md"),
|
|
2111
|
-
`\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` entry
|
|
2287
|
+
`\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` declaration.\nDECLARE-FIRST: \`monty add page <name>\` declares the entry (through the\nschema door on workspace-owned apps) before writing the route — a save\ncarrying an undeclared route refuses. System pages (table views) stay\nshell-rendered — only build bespoke UI here. \`monty dev\` serves both.\nAfter every meaningful change verified in dev, run\n\`monty save "<what changed>"\` — it pushes the work to the cloud copy,\nlike \`git push main\`.\n\nEvery custom page opens with \`PageHeader\` from \`@montytools/sdk/ui\` —\nthe same bar the shell renders on system pages (page actions go in it as\n\`PageHeaderButton\`s, \`primary\` for the one main action). Also there:\n\`FloatingBar\`/\`FloatingBarButton\` and the Lyra table classes\n\`SURFACE\`/\`THEAD\`/\`TH\`/\`ROW\`/\`CHIP\`.\n`,
|
|
2112
2288
|
);
|
|
2113
2289
|
}
|
|
2114
2290
|
|
|
2291
|
+
// The generated route imports @montytools/sdk/ui, whose Tailwind classes
|
|
2292
|
+
// only compile if the app's CSS scans the sdk dist — older scaffolds
|
|
2293
|
+
// predate the @source line.
|
|
2294
|
+
const cssPath = join(appDir, "src", "index.css");
|
|
2295
|
+
if (existsSync(cssPath)) {
|
|
2296
|
+
const css = readFileSync(cssPath, "utf8");
|
|
2297
|
+
if (!css.includes("@montytools/sdk/dist/ui.js")) {
|
|
2298
|
+
const lines = css.split("\n");
|
|
2299
|
+
let lastImport = -1;
|
|
2300
|
+
lines.forEach((l, i) => { if (/^@import\s/.test(l)) lastImport = i; });
|
|
2301
|
+
lines.splice(lastImport + 1, 0, '@source "../node_modules/@montytools/sdk/dist/ui.js";');
|
|
2302
|
+
writeFileSync(cssPath, lines.join("\n"));
|
|
2303
|
+
console.log("config: src/index.css now scans @montytools/sdk/ui (Tailwind @source)");
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2115
2307
|
// The page route: a real, working start — SDK data hooks, shell-aware.
|
|
2116
2308
|
mkdirSync(dirname(routeFile), { recursive: true });
|
|
2309
|
+
const pageTitle = (pageName[0].toUpperCase() + pageName.slice(1)).replace(/-/g, " ");
|
|
2117
2310
|
writeFileSync(routeFile, `import { createFileRoute } from "@tanstack/react-router";
|
|
2311
|
+
import { PageHeader } from "@montytools/sdk/ui";
|
|
2118
2312
|
|
|
2119
2313
|
export const Route = createFileRoute("/${pageName}")({
|
|
2120
2314
|
component: ${pageComponentName(pageName)},
|
|
@@ -2122,41 +2316,49 @@ export const Route = createFileRoute("/${pageName}")({
|
|
|
2122
2316
|
|
|
2123
2317
|
// A CUSTOM page: bespoke UI mounted inside the platform shell at
|
|
2124
2318
|
// /apps/<slug>/${pageName}. Data comes from @montytools/sdk hooks
|
|
2125
|
-
// (useList/useInsert/…) against the same tables the shell renders.
|
|
2319
|
+
// (useList/useInsert/…) against the same tables the shell renders. The
|
|
2320
|
+
// PageHeader bar is the same chrome system pages wear — keep it first, put
|
|
2321
|
+
// page actions in it (PageHeaderButton).
|
|
2126
2322
|
function ${pageComponentName(pageName)}() {
|
|
2127
2323
|
return (
|
|
2128
|
-
<
|
|
2129
|
-
<
|
|
2130
|
-
<
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2324
|
+
<div className="flex h-full min-h-dvh flex-col">
|
|
2325
|
+
<PageHeader title="${pageTitle}" />
|
|
2326
|
+
<main className="min-h-0 flex-1 overflow-auto p-6">
|
|
2327
|
+
<p className="text-sm text-muted-foreground">
|
|
2328
|
+
Build this page. It ships with the app on the next \`monty save\`.
|
|
2329
|
+
</p>
|
|
2330
|
+
</main>
|
|
2331
|
+
</div>
|
|
2134
2332
|
);
|
|
2135
2333
|
}
|
|
2136
2334
|
`);
|
|
2137
2335
|
console.log(`page: src/routes/${pageName}.tsx`);
|
|
2138
2336
|
|
|
2139
|
-
//
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
if (
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2337
|
+
// Manifest-less apps only: the config file is still their editor, so the
|
|
2338
|
+
// entry registers there (workspace-owned apps declared through the door
|
|
2339
|
+
// above — a config edit would be inert).
|
|
2340
|
+
if (declared === "config") {
|
|
2341
|
+
const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
|
|
2342
|
+
let next = null;
|
|
2343
|
+
if (/^(\s*)pages:\s*{/m.test(config)) {
|
|
2344
|
+
next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
|
|
2345
|
+
} else {
|
|
2346
|
+
// No pages block: add one right before the config's closing `});`.
|
|
2347
|
+
const close = config.lastIndexOf("});");
|
|
2348
|
+
if (close !== -1) {
|
|
2349
|
+
next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
if (next) {
|
|
2353
|
+
writeFileSync(configPath, next);
|
|
2354
|
+
console.log(`config: pages.${pageName} registered in monty.config.ts`);
|
|
2355
|
+
} else {
|
|
2356
|
+
console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
|
|
2149
2357
|
}
|
|
2150
|
-
}
|
|
2151
|
-
if (next) {
|
|
2152
|
-
writeFileSync(configPath, next);
|
|
2153
|
-
console.log(`config: pages.${pageName} registered in monty.config.ts`);
|
|
2154
|
-
} else {
|
|
2155
|
-
console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
|
|
2156
2358
|
}
|
|
2157
2359
|
|
|
2158
2360
|
console.log(`added: custom page "${pageName}"`);
|
|
2159
|
-
console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty
|
|
2361
|
+
console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
|
|
2160
2362
|
}
|
|
2161
2363
|
|
|
2162
2364
|
function pageComponentName(pageName) {
|
|
@@ -2246,7 +2448,12 @@ async function docs() {
|
|
|
2246
2448
|
// readable back. Read from the arg, then a TTY prompt, then stdin (piping).
|
|
2247
2449
|
async function secret() {
|
|
2248
2450
|
const appDir = requireAppDir("secret");
|
|
2249
|
-
|
|
2451
|
+
// Identity comes from the stamp when it exists — no config compile for
|
|
2452
|
+
// one slug read.
|
|
2453
|
+
const stamped = readAppJson(appDir);
|
|
2454
|
+
const meta = typeof stamped?.slug === "string" && stamped.slug
|
|
2455
|
+
? { slug: stamped.slug }
|
|
2456
|
+
: await compileConfig(appDir);
|
|
2250
2457
|
const config = loadConfig();
|
|
2251
2458
|
if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
2252
2459
|
const [sub, name] = rest.filter((a) => !a.startsWith("-"));
|
|
@@ -2283,13 +2490,25 @@ async function secret() {
|
|
|
2283
2490
|
console.log(del ? `secret: removed ${name} from ${meta.slug}` : `secret: set ${name} on ${meta.slug} (write-only; not readable back)`);
|
|
2284
2491
|
}
|
|
2285
2492
|
|
|
2286
|
-
// ── monty
|
|
2493
|
+
// ── monty save ─────────────────────────────────────────────────────────────
|
|
2494
|
+
// Push the working copy to the cloud copy, like `git push main`. `deploy` is
|
|
2495
|
+
// the compat alias; both run the same pipeline (build + typecheck gate every
|
|
2496
|
+
// save, then one multipart POST). The optional message rides the deploy meta
|
|
2497
|
+
// so the platform can narrate the save later.
|
|
2287
2498
|
async function deploy() {
|
|
2288
|
-
const appDir = requireAppDir(
|
|
2499
|
+
const appDir = requireAppDir(command);
|
|
2289
2500
|
ensureSdk(appDir);
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
}
|
|
2501
|
+
const message = rest.find((a) => !a.startsWith("-")) ?? null;
|
|
2502
|
+
const saveJson = (state) => writeSaveJson(appDir, { message, ...state });
|
|
2503
|
+
saveJson({ status: "saving" });
|
|
2504
|
+
saveFailNote = (code, fix) => saveJson({ status: "error", detail: fix });
|
|
2505
|
+
process.on("exit", (exitCode) => {
|
|
2506
|
+
// A crash that never reached fail() (network throw, unhandled rejection)
|
|
2507
|
+
// must not leave the chip on "Saving…" forever.
|
|
2508
|
+
if (exitCode !== 0 && saveFailNote) {
|
|
2509
|
+
saveJson({ status: "error", detail: "The save stopped before finishing. Run `monty save` again." });
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2293
2512
|
const config = loadConfig();
|
|
2294
2513
|
if (!config?.key) {
|
|
2295
2514
|
fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
|
|
@@ -2300,6 +2519,21 @@ async function deploy() {
|
|
|
2300
2519
|
console.log("compile: monty.config.ts");
|
|
2301
2520
|
const meta = await compileConfig(appDir);
|
|
2302
2521
|
console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
|
|
2522
|
+
// `monty save "what changed"` — the message rides the meta for the
|
|
2523
|
+
// platform to render as this save's Activity row.
|
|
2524
|
+
if (message) meta.message = message;
|
|
2525
|
+
|
|
2526
|
+
// Registry-owned apps ship IMPLEMENTATION ONLY: manifest, schema, rules,
|
|
2527
|
+
// and exposure are door-owned (the server holds them regardless — not
|
|
2528
|
+
// sending them keeps the wire honest). Code-door declarations
|
|
2529
|
+
// (publicFns, schedule, pages, functions) still ride.
|
|
2530
|
+
const registryOwned = readAppJson(appDir)?.registryOwned === true;
|
|
2531
|
+
if (registryOwned) {
|
|
2532
|
+
delete meta.schemaJson;
|
|
2533
|
+
delete meta.manifest;
|
|
2534
|
+
delete meta.exposure;
|
|
2535
|
+
delete meta.rules;
|
|
2536
|
+
}
|
|
2303
2537
|
|
|
2304
2538
|
// CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
|
|
2305
2539
|
// the platform shell renders the app.
|
|
@@ -2310,8 +2544,8 @@ async function deploy() {
|
|
|
2310
2544
|
run(appDir, "build", ["npx", "vite", "build"],
|
|
2311
2545
|
"The production build failed. Read the vite error above; it names the file to fix.");
|
|
2312
2546
|
run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
|
|
2313
|
-
"TypeScript errors above. Fix them in the listed files; `monty
|
|
2314
|
-
} else if (!meta.manifest) {
|
|
2547
|
+
"TypeScript errors above. Fix them in the listed files; `monty save` never uploads code that does not compile.");
|
|
2548
|
+
} else if (!registryOwned && !meta.manifest) {
|
|
2315
2549
|
fail("MANIFEST_MISSING",
|
|
2316
2550
|
"This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
|
|
2317
2551
|
}
|
|
@@ -2347,9 +2581,15 @@ async function deploy() {
|
|
|
2347
2581
|
`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.`);
|
|
2348
2582
|
}
|
|
2349
2583
|
}
|
|
2350
|
-
meta.fns
|
|
2584
|
+
// Wire compat: meta.fns stays the UNION (older hosts gate dispatch on
|
|
2585
|
+
// it); meta.datasets is the additive split newer hosts classify with.
|
|
2586
|
+
meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
|
|
2587
|
+
if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
|
|
2351
2588
|
form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
|
|
2352
|
-
|
|
2589
|
+
const bundled = [];
|
|
2590
|
+
if (serverBundle.fns.length > 0) bundled.push(`${serverBundle.fns.length} function(s) (${serverBundle.fns.join(", ")})`);
|
|
2591
|
+
if (serverBundle.datasets.length > 0) bundled.push(`${serverBundle.datasets.length} dataset(s) (${serverBundle.datasets.join(", ")})`);
|
|
2592
|
+
console.log(`fns: bundled ${bundled.join(" + ")}`);
|
|
2353
2593
|
if (publicFns.length > 0) {
|
|
2354
2594
|
console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
|
|
2355
2595
|
}
|
|
@@ -2357,6 +2597,16 @@ async function deploy() {
|
|
|
2357
2597
|
console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
|
|
2358
2598
|
}
|
|
2359
2599
|
}
|
|
2600
|
+
// 3a½) Custom pages, by route-file convention (top-level src/routes/
|
|
2601
|
+
// files) — registered as fnsJson.pages so the shell's nav knows what this
|
|
2602
|
+
// bundle ships without a manifest entry.
|
|
2603
|
+
if (!configOnly) {
|
|
2604
|
+
const pages = discoverPages(appDir);
|
|
2605
|
+
if (pages.length > 0) {
|
|
2606
|
+
meta.pages = pages;
|
|
2607
|
+
console.log(`pages: ${pages.map((p) => p.name).join(", ")}`);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2360
2610
|
// 3b) SOURCE snapshot rides every publish. Without it the platform keeps
|
|
2361
2611
|
// only the minified bundle and the sole copy of the app's code is this
|
|
2362
2612
|
// folder — delete it and the source is gone forever. The snapshot is what
|
|
@@ -2366,14 +2616,14 @@ async function deploy() {
|
|
|
2366
2616
|
{
|
|
2367
2617
|
const packed = packSource(appDir);
|
|
2368
2618
|
if (packed === null) {
|
|
2369
|
-
console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this
|
|
2619
|
+
console.log("source: WARNING — could not pack a snapshot (tar failed); `monty pull` will not work for this save.");
|
|
2370
2620
|
} else if (packed.tooLarge) {
|
|
2371
2621
|
console.log("source: WARNING — snapshot exceeds 10 MB, skipped; `monty pull` will not work for this app. Remove large assets from the app folder.");
|
|
2372
2622
|
} else {
|
|
2373
2623
|
sourceHash = packed.hash;
|
|
2374
2624
|
meta.sourceHash = sourceHash;
|
|
2375
2625
|
form.set("source", new Blob([packed.buf]), "source.tar.gz");
|
|
2376
|
-
console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this
|
|
2626
|
+
console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
|
|
2377
2627
|
}
|
|
2378
2628
|
}
|
|
2379
2629
|
// V2 schema CAS: prove which stored manifest this checkout last synced,
|
|
@@ -2409,9 +2659,14 @@ async function deploy() {
|
|
|
2409
2659
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
|
|
2410
2660
|
}
|
|
2411
2661
|
console.log(`origin: ${body.origin}`);
|
|
2412
|
-
console.log(`
|
|
2413
|
-
|
|
2414
|
-
|
|
2662
|
+
console.log(`saved: ${body.url} (version ${body.version})`);
|
|
2663
|
+
if (body.manifestDrift) {
|
|
2664
|
+
// The code shipped; the config was HELD — the stored schema changed
|
|
2665
|
+
// since this checkout last synced (another editor).
|
|
2666
|
+
console.log(`schema drift (remote changes):${body.manifestDrift.summary ? `\n${body.manifestDrift.summary}` : ""}`);
|
|
2667
|
+
console.log(`config push held: ${body.manifestDrift.fix ?? "Run `monty schema pull`, merge, then deploy again."}`);
|
|
2668
|
+
} else if (meta.manifest !== undefined) {
|
|
2669
|
+
// The manifest just published IS the new CAS base.
|
|
2415
2670
|
writeSchemaState(appDir, manifestHash(meta.manifest));
|
|
2416
2671
|
}
|
|
2417
2672
|
// Stamp what was published — pull uses this to tell "unchanged since last
|
|
@@ -2422,15 +2677,22 @@ async function deploy() {
|
|
|
2422
2677
|
JSON.stringify({ hash: sourceHash, syncedAt: Date.now() }) + "\n",
|
|
2423
2678
|
);
|
|
2424
2679
|
}
|
|
2680
|
+
saveFailNote = null;
|
|
2681
|
+
saveJson({ status: "saved" });
|
|
2425
2682
|
}
|
|
2426
2683
|
|
|
2427
|
-
// Bundle server
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2684
|
+
// Bundle the app's server code (if present) into ONE Worker script: server/
|
|
2685
|
+
// index.ts (functions — custom code) and datasets/index.ts (defineDataset
|
|
2686
|
+
// table feeds) merge into a generated entry wrapped with @montytools/sdk/
|
|
2687
|
+
// fn-worker's makeFnWorker; esbuild bundles it for workerd. node: imports are
|
|
2688
|
+
// rejected at compile time — Live runs on Cloudflare Workers, not Node.
|
|
2689
|
+
// Returns { code, fns, datasets } (per-folder export names) or null.
|
|
2431
2690
|
async function bundleServerFns(appDir, schedule) {
|
|
2432
2691
|
const serverEntry = join(appDir, "server", "index.ts");
|
|
2433
|
-
|
|
2692
|
+
const datasetsEntry = join(appDir, "datasets", "index.ts");
|
|
2693
|
+
const hasServer = existsSync(serverEntry);
|
|
2694
|
+
const hasDatasets = existsSync(datasetsEntry);
|
|
2695
|
+
if (!hasServer && !hasDatasets) return null;
|
|
2434
2696
|
const { build } = await import("esbuild");
|
|
2435
2697
|
const tmpDir = join(appDir, ".monty");
|
|
2436
2698
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -2440,9 +2702,10 @@ async function bundleServerFns(appDir, schedule) {
|
|
|
2440
2702
|
// hands back only the matching cron expression, so the worker needs the
|
|
2441
2703
|
// expression→fn mapping at runtime.
|
|
2442
2704
|
writeFileSync(entry, [
|
|
2443
|
-
`import * as appFns from "../server/index";`,
|
|
2705
|
+
hasServer ? `import * as appFns from "../server/index";` : `const appFns = {};`,
|
|
2706
|
+
hasDatasets ? `import * as appDatasets from "../datasets/index";` : `const appDatasets = {};`,
|
|
2444
2707
|
`import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
|
|
2445
|
-
`export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
|
|
2708
|
+
`export default makeFnWorker({ ...appFns, ...appDatasets }, { schedule: ${JSON.stringify(schedule ?? {})} });`,
|
|
2446
2709
|
].join("\n"));
|
|
2447
2710
|
// Fail the deploy if server code reaches for Node built-ins — a Worker
|
|
2448
2711
|
// can't run them, and a silent runtime crash on Live is the worst outcome.
|
|
@@ -2471,15 +2734,20 @@ async function bundleServerFns(appDir, schedule) {
|
|
|
2471
2734
|
logLevel: "silent",
|
|
2472
2735
|
plugins: [banPlatformImports],
|
|
2473
2736
|
});
|
|
2474
|
-
fns = discoverFnExports(serverEntry);
|
|
2475
|
-
|
|
2476
|
-
|
|
2737
|
+
fns = hasServer ? discoverFnExports(serverEntry) : [];
|
|
2738
|
+
const datasets = hasDatasets ? discoverFnExports(datasetsEntry) : [];
|
|
2739
|
+
const dup = fns.filter((f) => datasets.includes(f));
|
|
2740
|
+
if (dup.length > 0) {
|
|
2741
|
+
fail("DUPLICATE_EXPORT", `"${dup[0]}" is exported from both server/index.ts and datasets/index.ts — one name, one home. Remove one of the two exports.`);
|
|
2477
2742
|
}
|
|
2478
|
-
|
|
2743
|
+
if (fns.length === 0 && datasets.length === 0) {
|
|
2744
|
+
fail("NO_FN_EXPORTS", "server/index.ts / datasets/index.ts exist but export no functions. Export named functions like `export async function score(args, ctx) {…}`, or remove the folder.");
|
|
2745
|
+
}
|
|
2746
|
+
return { code: readFileSync(out, "utf8"), fns, datasets };
|
|
2479
2747
|
} catch (e) {
|
|
2480
|
-
if (e?.code === "NO_FN_EXPORTS") throw e; // fail() already exited; guard for safety
|
|
2748
|
+
if (e?.code === "NO_FN_EXPORTS" || e?.code === "DUPLICATE_EXPORT") throw e; // fail() already exited; guard for safety
|
|
2481
2749
|
const msg = e?.errors?.[0]?.text ?? e?.message ?? String(e);
|
|
2482
|
-
fail("FN_BUNDLE_FAILED", `Could not bundle server
|
|
2750
|
+
fail("FN_BUNDLE_FAILED", `Could not bundle the app's server code: ${msg}`);
|
|
2483
2751
|
} finally {
|
|
2484
2752
|
rmSync(entry, { force: true });
|
|
2485
2753
|
rmSync(out, { force: true });
|
|
@@ -2503,7 +2771,7 @@ function discoverFnExports(serverEntry) {
|
|
|
2503
2771
|
}
|
|
2504
2772
|
|
|
2505
2773
|
// Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
|
|
2506
|
-
//
|
|
2774
|
+
// the session heartbeat's last good schema through transient config breakage.
|
|
2507
2775
|
async function compileConfig(appDir, { soft = false } = {}) {
|
|
2508
2776
|
try {
|
|
2509
2777
|
// Config-only apps (no SPA) always compile a manifest: the platform
|
|
@@ -2542,7 +2810,7 @@ function walk(dir) {
|
|
|
2542
2810
|
return out;
|
|
2543
2811
|
}
|
|
2544
2812
|
|
|
2545
|
-
// ── cron matching (the
|
|
2813
|
+
// ── cron matching (the session's cron ticker in `monty dev`) ─────────────
|
|
2546
2814
|
// UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
|
|
2547
2815
|
// names (JAN, MON). Deliberately forgiving: an unparsable field simply never
|
|
2548
2816
|
// matches locally — Cloudflare is the syntax authority at deploy, so a bad
|
|
@@ -2603,15 +2871,14 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
|
|
|
2603
2871
|
// terminal — no browser, no dev session. Auth is the mk_ key exchanged at
|
|
2604
2872
|
// /api/dev-token for a 5-minute workspace token (member lane, org_id from
|
|
2605
2873
|
// the verified JWT), then the 6 public records functions over Convex's HTTP
|
|
2606
|
-
// API. `app`
|
|
2607
|
-
//
|
|
2608
|
-
// shell uses). Results are ONE JSON document on stdout so agents can pipe.
|
|
2874
|
+
// API. `app` is the plain slug — every app has ONE set of records.
|
|
2875
|
+
// Results are ONE JSON document on stdout so agents can pipe.
|
|
2609
2876
|
|
|
2610
2877
|
const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
|
|
2611
2878
|
|
|
2612
2879
|
// rest, minus flags AND their values — `monty data list leads --app crm`
|
|
2613
|
-
// must not read "crm" as a positional. Boolean flags
|
|
2614
|
-
//
|
|
2880
|
+
// must not read "crm" as a positional. Boolean flags have no value and
|
|
2881
|
+
// are skipped alone.
|
|
2615
2882
|
function dataPositionals() {
|
|
2616
2883
|
const out = [];
|
|
2617
2884
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -2645,7 +2912,10 @@ function resolveDataApp() {
|
|
|
2645
2912
|
if (!slug) {
|
|
2646
2913
|
fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
|
|
2647
2914
|
}
|
|
2648
|
-
|
|
2915
|
+
if (rest.includes("--studio")) {
|
|
2916
|
+
fail("STUDIO_REMOVED", "The session sandbox is gone — every app has one set of records, and data verbs always target it. Drop --studio.");
|
|
2917
|
+
}
|
|
2918
|
+
return slug;
|
|
2649
2919
|
}
|
|
2650
2920
|
|
|
2651
2921
|
// mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
|
|
@@ -2712,7 +2982,7 @@ function flattenRow(doc) {
|
|
|
2712
2982
|
}
|
|
2713
2983
|
|
|
2714
2984
|
function dataUsage() {
|
|
2715
|
-
console.log("usage: monty data <verb> [table] [flags] read/write an app's
|
|
2985
|
+
console.log("usage: monty data <verb> [table] [flags] read/write an app's records");
|
|
2716
2986
|
console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
|
|
2717
2987
|
console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
|
|
2718
2988
|
console.log(" get <table> <id>");
|
|
@@ -2720,7 +2990,7 @@ function dataUsage() {
|
|
|
2720
2990
|
console.log(" update <table> <id> --data '<json>' [--unset field,field]");
|
|
2721
2991
|
console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
|
|
2722
2992
|
console.log(" remove <table> <id>");
|
|
2723
|
-
console.log("target: --app <slug> (or run inside the app folder)
|
|
2993
|
+
console.log("target: --app <slug> (or run inside the app folder)");
|
|
2724
2994
|
process.exit(1);
|
|
2725
2995
|
}
|
|
2726
2996
|
|
|
@@ -2866,34 +3136,99 @@ if (command !== "dev" && command !== "logs") {
|
|
|
2866
3136
|
installSkills({ appDir: findAppRoot(process.cwd()) });
|
|
2867
3137
|
}
|
|
2868
3138
|
|
|
2869
|
-
// ── monty schema — the
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
3139
|
+
// ── monty schema — the manifest door ──────────────────────────────────────
|
|
3140
|
+
// The app's data half (tables, field algebra, metrics, settings, pages)
|
|
3141
|
+
// lives ONLY in the workspace. `monty schema [slug]` prints the stored
|
|
3142
|
+
// manifest as JSON (and stamps the CAS base); edit that JSON and
|
|
3143
|
+
// `monty schema set <file|->` writes it back through the one landing —
|
|
3144
|
+
// validated server-side, additive-only by default, CAS against what you
|
|
3145
|
+
// read. `monty schema pull` (legacy) regenerates monty.config.ts.
|
|
2873
3146
|
async function schemaCmd() {
|
|
2874
3147
|
const verb = rest[0];
|
|
2875
|
-
if (verb !== "pull") {
|
|
2876
|
-
console.log("usage: monty schema pull [slug] [--force]");
|
|
2877
|
-
console.log(" pull regenerate monty.config.ts from the app's stored manifest (.bak kept; --force discards local schema edits)");
|
|
2878
|
-
process.exit(verb ? 1 : 0);
|
|
2879
|
-
}
|
|
2880
3148
|
const { host, key } = loadConfig() ?? {};
|
|
2881
3149
|
if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
2882
|
-
const appDir = findAppRoot(process.cwd())
|
|
2883
|
-
|
|
2884
|
-
if (
|
|
3150
|
+
const appDir = findAppRoot(process.cwd());
|
|
3151
|
+
|
|
3152
|
+
if (verb === "pull") {
|
|
3153
|
+
const dir = appDir ?? process.cwd();
|
|
3154
|
+
let slug = rest.slice(1).find((a) => !a.startsWith("--"));
|
|
3155
|
+
if (!slug) {
|
|
3156
|
+
try {
|
|
3157
|
+
slug = (await compileAppConfig(dir)).slug;
|
|
3158
|
+
} catch {
|
|
3159
|
+
fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
await schemaPull({
|
|
3163
|
+
appDir: dir, host, key, slug,
|
|
3164
|
+
force: rest.includes("--force"),
|
|
3165
|
+
compileAppConfig,
|
|
3166
|
+
fail,
|
|
3167
|
+
});
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
if (verb === "set") {
|
|
3172
|
+
const target = rest[1];
|
|
3173
|
+
if (!target) {
|
|
3174
|
+
fail("SCHEMA_USAGE", "Usage: monty schema set <file.json|-> [--allow-breaking] — the JSON is a full manifest (start from `monty schema`).");
|
|
3175
|
+
}
|
|
3176
|
+
let raw;
|
|
3177
|
+
try {
|
|
3178
|
+
raw = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
|
|
3179
|
+
} catch {
|
|
3180
|
+
fail("SCHEMA_USAGE", `Could not read ${target === "-" ? "stdin" : target}. Pass a manifest JSON file, or - for stdin.`);
|
|
3181
|
+
}
|
|
3182
|
+
let manifest;
|
|
2885
3183
|
try {
|
|
2886
|
-
|
|
3184
|
+
manifest = JSON.parse(raw);
|
|
2887
3185
|
} catch {
|
|
2888
|
-
fail("
|
|
3186
|
+
fail("BAD_MANIFEST_JSON", "That is not valid JSON. Start from `monty schema` output, edit, and set the whole document back.");
|
|
3187
|
+
}
|
|
3188
|
+
const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
|
|
3189
|
+
if (!slug) fail("INVALID_SLUG", "The manifest carries no slug and this is not an app folder — set `slug` in the JSON.");
|
|
3190
|
+
// CAS: prove which stored manifest this edit was based on (stamped by
|
|
3191
|
+
// the last `monty schema` read in this folder). Absent = trusting push.
|
|
3192
|
+
const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
|
|
3193
|
+
const res = await fetch(`${host}/api/schema`, {
|
|
3194
|
+
method: "POST",
|
|
3195
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
3196
|
+
body: JSON.stringify({
|
|
3197
|
+
slug,
|
|
3198
|
+
manifest,
|
|
3199
|
+
...(base ? { baseHash: base } : {}),
|
|
3200
|
+
...(rest.includes("--allow-breaking") ? { allowBreaking: true } : {}),
|
|
3201
|
+
}),
|
|
3202
|
+
});
|
|
3203
|
+
const body = await res.json().catch(() => null);
|
|
3204
|
+
if (!res.ok || !body?.ok) {
|
|
3205
|
+
if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
|
|
3206
|
+
console.log(`schema drift (remote changes):\n${body.summary}`);
|
|
3207
|
+
}
|
|
3208
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the manifest failed — is the Monty host reachable?");
|
|
2889
3209
|
}
|
|
3210
|
+
if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
|
|
3211
|
+
console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
|
|
3212
|
+
return;
|
|
2890
3213
|
}
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3214
|
+
|
|
3215
|
+
// Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
|
|
3216
|
+
// JSON (pipe it to a file, edit, `monty schema set` it back).
|
|
3217
|
+
const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
|
|
3218
|
+
if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
|
|
3219
|
+
const res = await fetch(`${host}/api/schema?slug=${slug}`, {
|
|
3220
|
+
headers: { authorization: `Bearer ${key}` },
|
|
2896
3221
|
});
|
|
3222
|
+
const body = await res.json().catch(() => null);
|
|
3223
|
+
if (!res.ok || !body?.ok) {
|
|
3224
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the manifest — check the connection and `monty login`.");
|
|
3225
|
+
}
|
|
3226
|
+
if (body.manifest === null) {
|
|
3227
|
+
fail("NO_MANIFEST", `"${slug}" has no stored manifest yet — it's a code-only app. Declare one by setting a full manifest JSON: monty schema set <file>.`);
|
|
3228
|
+
}
|
|
3229
|
+
if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
|
|
3230
|
+
console.error(`# ${slug} — manifest hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
|
|
3231
|
+
console.log(JSON.stringify(body.manifest, null, 2));
|
|
2897
3232
|
}
|
|
2898
3233
|
|
|
2899
3234
|
switch (command) {
|
|
@@ -2907,7 +3242,7 @@ switch (command) {
|
|
|
2907
3242
|
await pull();
|
|
2908
3243
|
break;
|
|
2909
3244
|
case "commit":
|
|
2910
|
-
|
|
3245
|
+
fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
|
|
2911
3246
|
break;
|
|
2912
3247
|
case "log":
|
|
2913
3248
|
case "versions":
|
|
@@ -2939,7 +3274,7 @@ switch (command) {
|
|
|
2939
3274
|
apps();
|
|
2940
3275
|
break;
|
|
2941
3276
|
case "skills":
|
|
2942
|
-
installSkills({ appDir: findAppRoot(process.cwd()), silent: false });
|
|
3277
|
+
installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
|
|
2943
3278
|
console.log("skills: up to date");
|
|
2944
3279
|
break;
|
|
2945
3280
|
case "install":
|
|
@@ -2951,6 +3286,7 @@ switch (command) {
|
|
|
2951
3286
|
case "typecheck":
|
|
2952
3287
|
typecheckApp();
|
|
2953
3288
|
break;
|
|
3289
|
+
case "save":
|
|
2954
3290
|
case "deploy":
|
|
2955
3291
|
await deploy();
|
|
2956
3292
|
break;
|
|
@@ -2964,14 +3300,13 @@ switch (command) {
|
|
|
2964
3300
|
await secret();
|
|
2965
3301
|
break;
|
|
2966
3302
|
default:
|
|
2967
|
-
console.log("usage: monty <login|create|pull|
|
|
3303
|
+
console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|skills>");
|
|
2968
3304
|
console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
|
|
2969
3305
|
console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
|
|
2970
3306
|
console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
|
|
2971
|
-
console.log("
|
|
2972
|
-
console.log("
|
|
2973
|
-
console.log("
|
|
2974
|
-
console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, publish results)");
|
|
3307
|
+
console.log(" log [slug] the app's source version history (one row per save)");
|
|
3308
|
+
console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app's session, or attach to a running one (live data, auto-auth)");
|
|
3309
|
+
console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, save results)");
|
|
2975
3310
|
console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
|
|
2976
3311
|
console.log(" components [query] list the curated component catalog");
|
|
2977
3312
|
console.log(" docs <name> view a component's source before installing");
|
|
@@ -2981,9 +3316,10 @@ switch (command) {
|
|
|
2981
3316
|
console.log(" install install app dependencies");
|
|
2982
3317
|
console.log(" build production build (vite, via monty)");
|
|
2983
3318
|
console.log(" typecheck typecheck (builds first if needed)");
|
|
2984
|
-
console.log("
|
|
3319
|
+
console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main` (build + typecheck gate it)");
|
|
3320
|
+
console.log(" deploy alias of save");
|
|
2985
3321
|
console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
|
|
2986
|
-
console.log(" schema
|
|
3322
|
+
console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
|
|
2987
3323
|
console.log(" skills install/refresh the agent build skill");
|
|
2988
3324
|
process.exit(command ? 1 : 0);
|
|
2989
3325
|
}
|