@montytools/cli 0.4.2 → 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 +893 -209
- package/lib/compile.mjs +2 -2
- package/lib/schemaCodegen.mjs +222 -0
- package/lib/schemaPull.mjs +83 -0
- package/package.json +4 -3
- 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 +114 -16
- 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";
|
|
@@ -16,6 +16,8 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import { createInterface } from "node:readline/promises";
|
|
17
17
|
import { CATALOG, REGISTRIES } from "./catalog.mjs";
|
|
18
18
|
import { CompileError, compileAppConfig } from "../lib/compile.mjs";
|
|
19
|
+
import { manifestHash } from "../lib/schemaCodegen.mjs";
|
|
20
|
+
import { readSchemaState, schemaPull, writeSchemaState } from "../lib/schemaPull.mjs";
|
|
19
21
|
|
|
20
22
|
// MONTY_HOME overrides the state root (default ~/.monty): config.json,
|
|
21
23
|
// apps/, and desktop.json all live under it. This is how a second, isolated
|
|
@@ -61,10 +63,30 @@ function flag(name) {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function fail(code, fix) {
|
|
66
|
+
// A failing save narrates itself to the desktop chip before exiting.
|
|
67
|
+
saveFailNote?.(code, fix);
|
|
68
|
+
saveFailNote = null;
|
|
64
69
|
console.error(`error: [MontyError ${code}] Fix: ${fix}`);
|
|
65
70
|
process.exit(1);
|
|
66
71
|
}
|
|
67
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
|
+
|
|
68
90
|
// ── Profiles & the .montyrc directory pin ──────────────────────────────────
|
|
69
91
|
// One key PER HOST (like kubectl contexts): logging into the local platform
|
|
70
92
|
// host never clobbers the prod key. Which host a command targets resolves,
|
|
@@ -255,6 +277,22 @@ function readMarker(path) {
|
|
|
255
277
|
}
|
|
256
278
|
}
|
|
257
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
|
+
|
|
258
296
|
function skillsCliAdd({ global = false, cwd = undefined } = {}) {
|
|
259
297
|
const args = ["-y", "skills", "add", SKILLS_SRC, "-y", "--copy", ...(global ? ["-g"] : []), ...SKILL_AGENTS];
|
|
260
298
|
const res = spawnSync("npx", args, { cwd, stdio: "ignore", timeout: 120_000 });
|
|
@@ -283,7 +321,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
283
321
|
if (process.env.MONTY_NO_SKILLS) return;
|
|
284
322
|
try {
|
|
285
323
|
let changed = false;
|
|
286
|
-
if (force || readMarker(GLOBAL_SKILLS_MARKER)
|
|
324
|
+
if (force || markerOutdated(readMarker(GLOBAL_SKILLS_MARKER))) {
|
|
287
325
|
if (!skillsCliAdd({ global: true })) manualInstall(null);
|
|
288
326
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
289
327
|
writeFileSync(GLOBAL_SKILLS_MARKER, CLI_VERSION + "\n");
|
|
@@ -291,7 +329,7 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
291
329
|
}
|
|
292
330
|
if (appDir) {
|
|
293
331
|
const marker = join(appDir, ".agents", "skills", "monty-build", "VERSION");
|
|
294
|
-
if (force || readMarker(marker)
|
|
332
|
+
if (force || markerOutdated(readMarker(marker))) {
|
|
295
333
|
if (!skillsCliAdd({ cwd: appDir })) manualInstall(appDir);
|
|
296
334
|
mkdirSync(dirname(marker), { recursive: true });
|
|
297
335
|
writeFileSync(marker, CLI_VERSION + "\n");
|
|
@@ -308,17 +346,42 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
308
346
|
// ── monty current / select / apps ───────────────────────────────────────────
|
|
309
347
|
// Folder management users never think about: every app lives in ~/Monty,
|
|
310
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
|
+
|
|
311
355
|
function findAppRoot(start) {
|
|
312
356
|
let d = start;
|
|
313
357
|
for (;;) {
|
|
314
|
-
if (
|
|
358
|
+
if (isAppRoot(d)) return d;
|
|
315
359
|
const parent = dirname(d);
|
|
316
360
|
if (parent === d) return null;
|
|
317
361
|
d = parent;
|
|
318
362
|
}
|
|
319
363
|
}
|
|
320
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
|
+
|
|
321
382
|
function readSlugFromConfig(dir) {
|
|
383
|
+
const stamped = readAppJson(dir)?.slug;
|
|
384
|
+
if (typeof stamped === "string" && stamped) return stamped;
|
|
322
385
|
try {
|
|
323
386
|
return /slug:\s*"([^"]+)"/.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
324
387
|
} catch {
|
|
@@ -327,6 +390,8 @@ function readSlugFromConfig(dir) {
|
|
|
327
390
|
}
|
|
328
391
|
|
|
329
392
|
function readIdFromConfig(dir) {
|
|
393
|
+
const stamped = readAppJson(dir)?.id;
|
|
394
|
+
if (typeof stamped === "string" && stamped) return stamped;
|
|
330
395
|
try {
|
|
331
396
|
return /^[ \t]*id:\s*"([^"]+)"/m.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
332
397
|
} catch {
|
|
@@ -338,7 +403,7 @@ function scanAppsHome(root) {
|
|
|
338
403
|
if (!existsSync(root)) return [];
|
|
339
404
|
return readdirSync(root)
|
|
340
405
|
.map((name) => join(root, name))
|
|
341
|
-
.filter((p) =>
|
|
406
|
+
.filter((p) => isAppRoot(p))
|
|
342
407
|
.map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
|
|
343
408
|
}
|
|
344
409
|
|
|
@@ -389,11 +454,10 @@ function apps() {
|
|
|
389
454
|
}
|
|
390
455
|
|
|
391
456
|
// Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
|
|
392
|
-
// tar.gz buffer + its sha256 — the snapshot unit `monty
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
// 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.
|
|
397
461
|
function packSource(appDir) {
|
|
398
462
|
mkdirSync(join(appDir, ".monty"), { recursive: true });
|
|
399
463
|
const srcTar = join(appDir, ".monty", "source-upload.tar.gz");
|
|
@@ -415,60 +479,12 @@ function packSource(appDir) {
|
|
|
415
479
|
}
|
|
416
480
|
|
|
417
481
|
function readSlug(appDir) {
|
|
418
|
-
|
|
419
|
-
return /slug:\s*"([^"]+)"/.exec(readFileSync(join(appDir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
420
|
-
} catch {
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// ── monty commit ───────────────────────────────────────────────────────────
|
|
426
|
-
// Version the app's source WITHOUT publishing: pack the tree, upload it as
|
|
427
|
-
// one line of history. Git commit with everything stripped except "track
|
|
428
|
-
// versions" — no branches, no diffs, no local repo; history lives in the
|
|
429
|
-
// workspace and survives this folder.
|
|
430
|
-
async function commit() {
|
|
431
|
-
const appDir = requireAppDir("commit");
|
|
432
|
-
const slug = readSlug(appDir);
|
|
433
|
-
if (!slug) fail("NO_SLUG", "monty.config.ts has no slug — fix the config, then retry.");
|
|
434
|
-
const { host, key } = loadConfig();
|
|
435
|
-
if (!key) fail("NOT_LOGGED_IN", "Committing stores the snapshot in your workspace. Run `monty login` first.");
|
|
436
|
-
const mIdx = rest.indexOf("-m");
|
|
437
|
-
const message = flag("message") ?? (mIdx >= 0 ? rest[mIdx + 1] : undefined) ?? "checkpoint";
|
|
438
|
-
const packed = packSource(appDir);
|
|
439
|
-
if (packed === null) fail("PACK_FAILED", "Packing the source failed (tar error). Retry; check the folder is readable.");
|
|
440
|
-
if (packed.tooLarge) fail("SOURCE_TOO_LARGE", "The source tree exceeds 10 MB (node_modules/dist excluded). Remove large assets, then retry.");
|
|
441
|
-
try {
|
|
442
|
-
const stamp = JSON.parse(readFileSync(join(appDir, ".monty", "source.json"), "utf8"));
|
|
443
|
-
if (stamp.hash === packed.hash) {
|
|
444
|
-
console.log(`nothing to commit — source unchanged since ${packed.hash.slice(0, 7)}`);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
} catch {
|
|
448
|
-
/* no stamp yet — first commit from this folder */
|
|
449
|
-
}
|
|
450
|
-
const form = new FormData();
|
|
451
|
-
form.set("monty", JSON.stringify({ slug, hash: packed.hash, message }));
|
|
452
|
-
form.set("source", new Blob([packed.buf]), "source.tar.gz");
|
|
453
|
-
const res = await fetch(`${host}/api/source`, {
|
|
454
|
-
method: "POST",
|
|
455
|
-
headers: { authorization: `Bearer ${key}` },
|
|
456
|
-
body: form,
|
|
457
|
-
});
|
|
458
|
-
const body = await res.json().catch(() => null);
|
|
459
|
-
if (!res.ok || !body?.ok) {
|
|
460
|
-
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Uploading the snapshot failed — check the connection and retry.");
|
|
461
|
-
}
|
|
462
|
-
writeFileSync(
|
|
463
|
-
join(appDir, ".monty", "source.json"),
|
|
464
|
-
JSON.stringify({ hash: packed.hash, syncedAt: Date.now() }) + "\n",
|
|
465
|
-
);
|
|
466
|
-
console.log(`committed: ${packed.hash.slice(0, 7)} "${message}" (${(packed.buf.byteLength / 1024).toFixed(0)} KB)`);
|
|
482
|
+
return readSlugFromConfig(appDir);
|
|
467
483
|
}
|
|
468
484
|
|
|
469
485
|
// ── monty log ──────────────────────────────────────────────────────────────
|
|
470
|
-
// The app's version history, newest first
|
|
471
|
-
//
|
|
486
|
+
// The app's version history, newest first — one row per `monty save`.
|
|
487
|
+
// (Not `monty logs` — that tails the dev shell.)
|
|
472
488
|
async function versionsLog() {
|
|
473
489
|
const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
|
|
474
490
|
if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
|
|
@@ -482,19 +498,19 @@ async function versionsLog() {
|
|
|
482
498
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not list versions — check the connection and `monty login`.");
|
|
483
499
|
}
|
|
484
500
|
if (body.versions.length === 0) {
|
|
485
|
-
console.log(`no versions of "${slug}" yet — \`monty
|
|
501
|
+
console.log(`no versions of "${slug}" yet — \`monty save\` creates the first one.`);
|
|
486
502
|
return;
|
|
487
503
|
}
|
|
488
504
|
for (const v of body.versions) {
|
|
489
505
|
const when = new Date(v.createdAt).toISOString().slice(0, 16).replace("T", " ");
|
|
490
|
-
console.log(`${v.hash.slice(0, 7)} ${when} ${v.
|
|
506
|
+
console.log(`${v.hash.slice(0, 7)} ${when} ${v.message}`);
|
|
491
507
|
}
|
|
492
508
|
console.log(`\nrestore one: monty pull ${slug} --version <hash> [--force]`);
|
|
493
509
|
}
|
|
494
510
|
|
|
495
511
|
// ── monty pull ─────────────────────────────────────────────────────────────
|
|
496
|
-
// Restore an app's
|
|
497
|
-
// `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
|
|
498
514
|
// how a second machine (or one that lost the folder) gets the code back.
|
|
499
515
|
// Refuses to touch an existing folder without --force — it may hold
|
|
500
516
|
// unpublished work the snapshot would destroy.
|
|
@@ -539,11 +555,11 @@ async function pull() {
|
|
|
539
555
|
expectedHash = matches[0].hash;
|
|
540
556
|
downloadUrl = `${host}/api/source?slug=${slug}&hash=${expectedHash}`;
|
|
541
557
|
} else if (!app.sourceHash) {
|
|
542
|
-
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.`);
|
|
543
559
|
}
|
|
544
560
|
const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
|
|
545
561
|
if (existsSync(target) && !rest.includes("--force")) {
|
|
546
|
-
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.`);
|
|
547
563
|
}
|
|
548
564
|
|
|
549
565
|
console.log(`pull: ${slug}${versionFlag ? ` @ ${expectedHash.slice(0, 7)}` : ""} <- ${host}`);
|
|
@@ -557,7 +573,7 @@ async function pull() {
|
|
|
557
573
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
558
574
|
const hash = createHash("sha256").update(buf).digest("hex");
|
|
559
575
|
if (hash !== expectedHash) {
|
|
560
|
-
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.");
|
|
561
577
|
}
|
|
562
578
|
|
|
563
579
|
// Extract into a staging folder, then move into place — a failed extract
|
|
@@ -571,7 +587,7 @@ async function pull() {
|
|
|
571
587
|
rmSync(tarFile, { force: true });
|
|
572
588
|
if (untar.status !== 0) {
|
|
573
589
|
rmSync(staging, { recursive: true, force: true });
|
|
574
|
-
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.");
|
|
575
591
|
}
|
|
576
592
|
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
577
593
|
renameSync(staging, target);
|
|
@@ -591,11 +607,112 @@ async function pull() {
|
|
|
591
607
|
join(target, ".monty", "source.json"),
|
|
592
608
|
JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
|
|
593
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
|
+
});
|
|
594
618
|
console.log(`pulled: ${target}`);
|
|
595
619
|
console.log("next: `monty install`, then `monty dev`.");
|
|
596
620
|
}
|
|
597
621
|
|
|
598
622
|
// ── monty create ───────────────────────────────────────────────────────────
|
|
623
|
+
// A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
|
|
624
|
+
// the platform shell renders it. The presence of index.html is the marker
|
|
625
|
+
// (every SPA template ships one; the config-only scaffold never does).
|
|
626
|
+
function isConfigOnlyApp(appDir) {
|
|
627
|
+
return !existsSync(join(appDir, "index.html"));
|
|
628
|
+
}
|
|
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
|
+
|
|
665
|
+
const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
|
|
666
|
+
|
|
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.
|
|
670
|
+
|
|
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.
|
|
675
|
+
- Formulas are strings in the Monty expression grammar, e.g.
|
|
676
|
+
\`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
|
|
677
|
+
ABOVE the formula and \`metrics.<name>\` are in scope.
|
|
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.
|
|
680
|
+
`;
|
|
681
|
+
|
|
682
|
+
function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
|
|
683
|
+
mkdirSync(target, { recursive: true });
|
|
684
|
+
writeFileSync(join(target, "monty.config.ts"), `import { defineApp } from "@montytools/sdk";
|
|
685
|
+
|
|
686
|
+
// This file IS the app: tables, derived fields, metrics, settings, pages.
|
|
687
|
+
// The Monty platform renders it — no src/, no build. Declare tables as zod
|
|
688
|
+
// objects; derive with rollup()/lookup()/formula(); see AGENTS.md.
|
|
689
|
+
export const app = defineApp({
|
|
690
|
+
id: "${appId}",
|
|
691
|
+
slug: "${slug}",
|
|
692
|
+
name: "${name}",
|
|
693
|
+
icon: "${icon}",
|
|
694
|
+
tables: {},
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
export type App = typeof app;
|
|
698
|
+
`);
|
|
699
|
+
writeFileSync(join(target, "package.json"), JSON.stringify({
|
|
700
|
+
name: slug,
|
|
701
|
+
private: true,
|
|
702
|
+
type: "module",
|
|
703
|
+
dependencies: { "@montytools/sdk": "latest", zod: "^4.4.3" },
|
|
704
|
+
}, null, 2) + "\n");
|
|
705
|
+
writeFileSync(join(target, "tsconfig.json"), JSON.stringify({
|
|
706
|
+
compilerOptions: {
|
|
707
|
+
target: "ES2022", module: "ESNext", moduleResolution: "bundler",
|
|
708
|
+
strict: true, skipLibCheck: true, noEmit: true,
|
|
709
|
+
},
|
|
710
|
+
include: ["monty.config.ts"],
|
|
711
|
+
}, null, 2) + "\n");
|
|
712
|
+
writeFileSync(join(target, ".gitignore"), "node_modules/\n.monty/\n");
|
|
713
|
+
writeFileSync(join(target, "AGENTS.md"), CONFIG_ONLY_AGENTS_MD);
|
|
714
|
+
}
|
|
715
|
+
|
|
599
716
|
async function create() {
|
|
600
717
|
const slug = rest.find((a) => !a.startsWith("--"));
|
|
601
718
|
if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
|
@@ -652,6 +769,51 @@ async function create() {
|
|
|
652
769
|
}
|
|
653
770
|
mkdirSync(dirname(target), { recursive: true });
|
|
654
771
|
|
|
772
|
+
// DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
|
|
773
|
+
// platform shell renders it; \`monty add page\` scaffolds a SPA the moment a
|
|
774
|
+
// bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
|
|
775
|
+
// (\`--config-only\` stays accepted as the now-default no-op).
|
|
776
|
+
if (!rest.includes("--spa")) {
|
|
777
|
+
console.log(`create: ${slug} -> ${target} (config-only)`);
|
|
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 });
|
|
782
|
+
// The user's brief lands at the top of AGENTS.md, same as SPA creates.
|
|
783
|
+
const brief = flag("description");
|
|
784
|
+
if (brief?.trim()) {
|
|
785
|
+
const agentsPath = join(target, "AGENTS.md");
|
|
786
|
+
writeFileSync(
|
|
787
|
+
agentsPath,
|
|
788
|
+
`# What to build: ${name}\n\n${brief.trim()}\n\n---\n\n` + readFileSync(agentsPath, "utf8"),
|
|
789
|
+
);
|
|
790
|
+
console.log("brief: AGENTS.md carries the app description");
|
|
791
|
+
}
|
|
792
|
+
const cBuildId = flag("build");
|
|
793
|
+
if (cBuildId && /^[a-z0-9]{10,64}$/i.test(cBuildId)) {
|
|
794
|
+
mkdirSync(join(target, ".monty"), { recursive: true });
|
|
795
|
+
writeFileSync(join(target, ".monty", "build"), cBuildId + "\n");
|
|
796
|
+
try {
|
|
797
|
+
await fetch(`${host}/api/build`, {
|
|
798
|
+
method: "POST",
|
|
799
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
800
|
+
body: JSON.stringify({ buildId: cBuildId, slug }),
|
|
801
|
+
});
|
|
802
|
+
} catch { /* progress signal only */ }
|
|
803
|
+
}
|
|
804
|
+
if (!existsSync(join(target, "CLAUDE.md"))) {
|
|
805
|
+
try {
|
|
806
|
+
symlinkSync("AGENTS.md", join(target, "CLAUDE.md"));
|
|
807
|
+
} catch {
|
|
808
|
+
writeFileSync(join(target, "CLAUDE.md"), "@AGENTS.md\n");
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
installSkills({ appDir: target });
|
|
812
|
+
console.log(`created: ${target}`);
|
|
813
|
+
console.log(`next: cd ${target} && monty install && monty dev`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
655
817
|
// Template is bundled into the published package (../template). Fall back to
|
|
656
818
|
// the monorepo path when running the CLI in-place during development.
|
|
657
819
|
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -676,7 +838,7 @@ async function create() {
|
|
|
676
838
|
// hard-excludes the real name from tarballs); the in-repo template has the
|
|
677
839
|
// real file. Normalize, and backfill for bundles that carried neither —
|
|
678
840
|
// without a .gitignore, tailwind v4's content scan includes .monty/ and
|
|
679
|
-
// full-reloads
|
|
841
|
+
// full-reloads the session on every dev.json touch.
|
|
680
842
|
const gitignorePath = join(target, ".gitignore");
|
|
681
843
|
if (existsSync(join(target, "gitignore"))) {
|
|
682
844
|
renameSync(join(target, "gitignore"), gitignorePath);
|
|
@@ -704,6 +866,9 @@ async function create() {
|
|
|
704
866
|
htmlPath,
|
|
705
867
|
readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
|
|
706
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 });
|
|
707
872
|
|
|
708
873
|
// The user's brief (--description, e.g. from the desktop's create dialog)
|
|
709
874
|
// goes to the TOP of AGENTS.md — agent harnesses can't be handed an initial
|
|
@@ -819,7 +984,7 @@ async function freePort(start) {
|
|
|
819
984
|
// Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
|
|
820
985
|
// minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
|
|
821
986
|
// SDK's vite plugin) and upgrades the app automatically before dev/deploy.
|
|
822
|
-
const MIN_SDK = "0.1
|
|
987
|
+
const MIN_SDK = "0.2.1";
|
|
823
988
|
const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
|
|
824
989
|
|
|
825
990
|
function installedSdkVersion(appDir) {
|
|
@@ -1099,12 +1264,12 @@ function printAttach(appDir, s) {
|
|
|
1099
1264
|
const beat = typeof s.lastHeartbeatAt === "number" ? Math.round((Date.now() - s.lastHeartbeatAt) / 1000) : null;
|
|
1100
1265
|
console.log(
|
|
1101
1266
|
beat !== null && beat > Math.round(DEV_JSON_STALE_MS / 1000)
|
|
1102
|
-
? `state: online (no heartbeat for ${beat}s —
|
|
1267
|
+
? `state: online (no heartbeat for ${beat}s — the workspace may show the session offline)`
|
|
1103
1268
|
: `state: online (heartbeat ${beat ?? "?"}s ago)`,
|
|
1104
1269
|
);
|
|
1105
|
-
if (s.
|
|
1270
|
+
if (s.host && s.slug) console.log(`app: ${s.host}/apps/${s.slug} — your app runs there while this is up`);
|
|
1106
1271
|
} else {
|
|
1107
|
-
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)");
|
|
1108
1273
|
}
|
|
1109
1274
|
}
|
|
1110
1275
|
console.log("logs: `monty logs -f` follows output; `monty logs -n 100` shows recent lines");
|
|
@@ -1226,11 +1391,11 @@ async function sweepOrphans(s) {
|
|
|
1226
1391
|
}
|
|
1227
1392
|
|
|
1228
1393
|
// ── monty dev ──────────────────────────────────────────────────────────────
|
|
1229
|
-
//
|
|
1230
|
-
// as the app's
|
|
1231
|
-
// at usemonty.dev while it runs.
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
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.
|
|
1234
1399
|
async function dev() {
|
|
1235
1400
|
const appDir = requireAppDir("dev");
|
|
1236
1401
|
|
|
@@ -1279,43 +1444,67 @@ async function dev() {
|
|
|
1279
1444
|
|
|
1280
1445
|
installSkills({ appDir });
|
|
1281
1446
|
ensureSdk(appDir);
|
|
1282
|
-
|
|
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);
|
|
1283
1458
|
const cfg = loadConfig();
|
|
1284
1459
|
const host = cfg?.host ?? DEFAULT_HOST;
|
|
1460
|
+
// CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
|
|
1461
|
+
// compile + push — the platform shell renders the app.
|
|
1462
|
+
const configOnly = isConfigOnlyApp(appDir);
|
|
1285
1463
|
// Auto-pick a free port (agents run several apps side by side); an
|
|
1286
1464
|
// explicit --port is honored strictly.
|
|
1287
1465
|
const requested = flag("port");
|
|
1288
|
-
const port = requested ? Number(requested) : await freePort(5173);
|
|
1466
|
+
const port = configOnly ? null : requested ? Number(requested) : await freePort(5173);
|
|
1289
1467
|
|
|
1290
|
-
|
|
1291
|
-
if (!
|
|
1292
|
-
|
|
1468
|
+
let child = null;
|
|
1469
|
+
if (!configOnly) {
|
|
1470
|
+
const viteBin = resolveViteBin(appDir);
|
|
1471
|
+
if (!viteBin) {
|
|
1472
|
+
fail("VITE_MISSING", "vite is not installed in this app. Run `monty install`, then `monty dev` again.");
|
|
1473
|
+
}
|
|
1474
|
+
console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
|
|
1475
|
+
child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
|
|
1476
|
+
cwd: appDir,
|
|
1477
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1478
|
+
});
|
|
1479
|
+
} else {
|
|
1480
|
+
console.log(`dev: config-only app "${meta.slug}" — no vite; watching monty.config.ts`);
|
|
1293
1481
|
}
|
|
1294
1482
|
|
|
1295
|
-
console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
|
|
1296
|
-
const child = spawn(process.execPath, [viteBin, "dev", "--port", String(port), "--strictPort"], {
|
|
1297
|
-
cwd: appDir,
|
|
1298
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1299
|
-
});
|
|
1300
|
-
|
|
1301
1483
|
let tunnelChild = null;
|
|
1302
|
-
let pubChild = null;
|
|
1303
1484
|
let hbTimer = null;
|
|
1304
1485
|
let touchTimer = null;
|
|
1305
1486
|
let cronTimer = null;
|
|
1306
|
-
let publishing = false;
|
|
1307
1487
|
let ended = false;
|
|
1308
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;
|
|
1309
1493
|
const devStartedAt = Date.now();
|
|
1310
1494
|
const sessionId = `dev_${randomBytes(16).toString("hex")}`;
|
|
1311
1495
|
const buildFile = join(appDir, ".monty", "build");
|
|
1312
1496
|
const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
|
|
1313
|
-
// The
|
|
1314
|
-
// edits to monty.config.ts are re-compiled (softly)
|
|
1315
|
-
// 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).
|
|
1316
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;
|
|
1317
1506
|
const configPath = join(appDir, "monty.config.ts");
|
|
1318
|
-
let configMtime = statSync(configPath).mtimeMs;
|
|
1507
|
+
let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
|
|
1319
1508
|
|
|
1320
1509
|
// Advertise this session. The touch timer (not the platform heartbeat,
|
|
1321
1510
|
// which starts minutes late or never when logged out) keeps updatedAt
|
|
@@ -1335,11 +1524,11 @@ async function dev() {
|
|
|
1335
1524
|
sessionId,
|
|
1336
1525
|
state: "starting",
|
|
1337
1526
|
loggedIn,
|
|
1338
|
-
appUrl: `http://localhost:${port}`,
|
|
1527
|
+
appUrl: configOnly ? null : `http://localhost:${port}`,
|
|
1339
1528
|
tunnelUrl: null,
|
|
1340
|
-
|
|
1341
|
-
previewUrl: loggedIn
|
|
1342
|
-
? `${host}/
|
|
1529
|
+
// Field names are wire contract (the desktop reads them).
|
|
1530
|
+
previewUrl: loggedIn && !configOnly
|
|
1531
|
+
? `${host}/apps/${meta.slug}?dev=${encodeURIComponent(`http://localhost:${port}`)}`
|
|
1343
1532
|
: null,
|
|
1344
1533
|
publishing: false,
|
|
1345
1534
|
lastHeartbeatAt: null,
|
|
@@ -1348,7 +1537,7 @@ async function dev() {
|
|
|
1348
1537
|
});
|
|
1349
1538
|
touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
|
|
1350
1539
|
|
|
1351
|
-
// The
|
|
1540
|
+
// The session cron runner: the Live counterpart is a real Cloudflare Cron
|
|
1352
1541
|
// Trigger on the app's fn-worker; here the CLI matches monty.config.ts
|
|
1353
1542
|
// `schedule` entries against the UTC clock once per minute and invokes the
|
|
1354
1543
|
// fn through the same /__monty/fn runtime (x-monty-schedule marks the
|
|
@@ -1384,6 +1573,7 @@ async function dev() {
|
|
|
1384
1573
|
cronTimer = setInterval(cronTick, 20_000);
|
|
1385
1574
|
|
|
1386
1575
|
async function refreshSchemaIfChanged() {
|
|
1576
|
+
if (registryOwned) return; // the doors own the schema; nothing to push
|
|
1387
1577
|
try {
|
|
1388
1578
|
const m = statSync(configPath).mtimeMs;
|
|
1389
1579
|
if (m === configMtime) return;
|
|
@@ -1392,7 +1582,7 @@ async function dev() {
|
|
|
1392
1582
|
if (fresh) {
|
|
1393
1583
|
currentMeta = fresh;
|
|
1394
1584
|
console.log(
|
|
1395
|
-
`schema: monty.config.ts changed —
|
|
1585
|
+
`schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
|
|
1396
1586
|
);
|
|
1397
1587
|
}
|
|
1398
1588
|
} catch { /* transient fs hiccup — next beat retries */ }
|
|
@@ -1417,11 +1607,10 @@ async function dev() {
|
|
|
1417
1607
|
if (hbTimer) clearInterval(hbTimer);
|
|
1418
1608
|
if (touchTimer) clearInterval(touchTimer);
|
|
1419
1609
|
if (cronTimer) clearInterval(cronTimer);
|
|
1420
|
-
try { pubChild?.kill(); } catch { /* already gone */ }
|
|
1421
1610
|
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
1422
1611
|
// vite is a direct child (no npx wrapper), so this actually kills it —
|
|
1423
1612
|
// a bare SIGTERM from the desktop must never orphan vite on the port.
|
|
1424
|
-
try { child
|
|
1613
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
1425
1614
|
sf.remove();
|
|
1426
1615
|
logSink.close();
|
|
1427
1616
|
await clearDevSession();
|
|
@@ -1433,9 +1622,8 @@ async function dev() {
|
|
|
1433
1622
|
if (hbTimer) clearInterval(hbTimer);
|
|
1434
1623
|
if (touchTimer) clearInterval(touchTimer);
|
|
1435
1624
|
if (cronTimer) clearInterval(cronTimer);
|
|
1436
|
-
try { pubChild?.kill(); } catch { /* already gone */ }
|
|
1437
1625
|
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
1438
|
-
try { child
|
|
1626
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
1439
1627
|
console.log(`dev-session: superseded — ${fix}`);
|
|
1440
1628
|
sf.remove(); // guarded — never deletes the new owner's file
|
|
1441
1629
|
logSink.close();
|
|
@@ -1455,7 +1643,7 @@ async function dev() {
|
|
|
1455
1643
|
headers: { authorization: `Bearer ${liveKey}`, "content-type": "application/json" },
|
|
1456
1644
|
body: JSON.stringify({
|
|
1457
1645
|
slug: meta.slug,
|
|
1458
|
-
tunnelUrl: originUrl,
|
|
1646
|
+
...(originUrl !== undefined ? { tunnelUrl: originUrl } : {}),
|
|
1459
1647
|
sessionId,
|
|
1460
1648
|
// Keep claiming until the first successful registration, but only
|
|
1461
1649
|
// within the lock's own 90s TTL window: after a takeover/crash the
|
|
@@ -1468,9 +1656,19 @@ async function dev() {
|
|
|
1468
1656
|
icon: currentMeta.icon,
|
|
1469
1657
|
buildId,
|
|
1470
1658
|
schemaJson: currentMeta.schemaJson,
|
|
1659
|
+
// App Manifest v2 (docs/manifest-v2.md) — present only for V2
|
|
1660
|
+
// configs; lands only for manifest-less apps (the doors own the
|
|
1661
|
+
// rest).
|
|
1662
|
+
manifest: currentMeta.manifest,
|
|
1663
|
+
// The CAS base for the manifest-less landing (see `monty schema
|
|
1664
|
+
// pull`).
|
|
1665
|
+
baseManifestHash:
|
|
1666
|
+
currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
|
|
1471
1667
|
// The expose block rides the same compile as the schema — the dev
|
|
1472
|
-
// visitor preview is gated on it
|
|
1668
|
+
// visitor preview of a manifest-less app is gated on it.
|
|
1473
1669
|
exposure: currentMeta.exposure,
|
|
1670
|
+
// Rules ride the same channel (manifest-less apps only).
|
|
1671
|
+
rules: currentMeta.rules,
|
|
1474
1672
|
}),
|
|
1475
1673
|
signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
|
|
1476
1674
|
});
|
|
@@ -1480,6 +1678,11 @@ async function dev() {
|
|
|
1480
1678
|
stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
|
|
1481
1679
|
return false;
|
|
1482
1680
|
}
|
|
1681
|
+
if (data?.code === "MANIFEST_DRIFT") {
|
|
1682
|
+
// Remote schema edits (another agent, via the API) — the terminal
|
|
1683
|
+
// the building agent is watching gets the fix, invariant #4.
|
|
1684
|
+
console.log(`schema drift (remote changes):${data.summary ? `\n${data.summary}` : ""}`);
|
|
1685
|
+
}
|
|
1483
1686
|
console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
|
|
1484
1687
|
// A dead key is a SIGNED-OUT session — advertise it so the desktop
|
|
1485
1688
|
// (which owns the session) can surface sign-in instead of letting
|
|
@@ -1489,43 +1692,77 @@ async function dev() {
|
|
|
1489
1692
|
}
|
|
1490
1693
|
return false;
|
|
1491
1694
|
}
|
|
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.
|
|
1706
|
+
try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
|
|
1707
|
+
}
|
|
1492
1708
|
if (!registeredOnce) {
|
|
1493
1709
|
registeredOnce = true;
|
|
1494
1710
|
sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1495
1711
|
} else {
|
|
1496
1712
|
sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1497
1713
|
}
|
|
1498
|
-
if (data?.
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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 } : {}),
|
|
1507
1725
|
});
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
+
}
|
|
1529
1766
|
}
|
|
1530
1767
|
return true;
|
|
1531
1768
|
} catch {
|
|
@@ -1536,11 +1773,12 @@ async function dev() {
|
|
|
1536
1773
|
|
|
1537
1774
|
async function startDevSession() {
|
|
1538
1775
|
if (!cfg?.key) {
|
|
1539
|
-
console.log("dev: not logged in — workspace
|
|
1776
|
+
console.log("dev: not logged in — workspace session disabled (run `monty login`)");
|
|
1540
1777
|
return;
|
|
1541
1778
|
}
|
|
1542
1779
|
await clearDevSession();
|
|
1543
|
-
|
|
1780
|
+
// Config-only sessions register WITHOUT an origin — nothing to iframe.
|
|
1781
|
+
let originUrl = configOnly ? undefined : `http://localhost:${port}`;
|
|
1544
1782
|
let tunnelUpdate = Promise.resolve();
|
|
1545
1783
|
let tunnelVersion = 0;
|
|
1546
1784
|
async function activateTunnelUrl(url, { initial = false } = {}) {
|
|
@@ -1557,17 +1795,17 @@ async function dev() {
|
|
|
1557
1795
|
if (ended || version !== tunnelVersion) return "superseded";
|
|
1558
1796
|
if (!dnsLive) {
|
|
1559
1797
|
if (initial) {
|
|
1560
|
-
console.log("tunnel: DNS never propagated —
|
|
1798
|
+
console.log("tunnel: DNS never propagated — session registered on localhost (visible on this machine's browser only)");
|
|
1561
1799
|
} else {
|
|
1562
|
-
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");
|
|
1563
1801
|
}
|
|
1564
1802
|
return "failed";
|
|
1565
1803
|
}
|
|
1566
1804
|
originUrl = url;
|
|
1567
1805
|
sf.write({ tunnelUrl: url });
|
|
1568
|
-
console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live;
|
|
1806
|
+
console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; session URL updated");
|
|
1569
1807
|
if (!initial && !(await heartbeat(originUrl))) {
|
|
1570
|
-
console.log("dev-session:
|
|
1808
|
+
console.log("dev-session: the session still has no registered tunnel; the next heartbeat will retry");
|
|
1571
1809
|
}
|
|
1572
1810
|
return "activated";
|
|
1573
1811
|
}
|
|
@@ -1579,7 +1817,7 @@ async function dev() {
|
|
|
1579
1817
|
console.log("tunnel: URL update failed — waiting for the next tunnel URL");
|
|
1580
1818
|
});
|
|
1581
1819
|
};
|
|
1582
|
-
if (!rest.includes("--no-tunnel")) {
|
|
1820
|
+
if (!configOnly && !rest.includes("--no-tunnel")) {
|
|
1583
1821
|
console.log("tunnel: starting (cloudflared quick tunnel)…");
|
|
1584
1822
|
// cloudflared output is teed to dev.log only (the terminal stays quiet,
|
|
1585
1823
|
// exactly as today) — post-mortems get the tunnel noise.
|
|
@@ -1591,22 +1829,46 @@ async function dev() {
|
|
|
1591
1829
|
console.log(`tunnel: ${t.url}`);
|
|
1592
1830
|
await activateTunnelUrl(t.url, { initial: true });
|
|
1593
1831
|
} else {
|
|
1594
|
-
console.log("tunnel: unavailable —
|
|
1832
|
+
console.log("tunnel: unavailable — session registered on localhost (visible in this machine's browser only)");
|
|
1595
1833
|
}
|
|
1596
1834
|
}
|
|
1597
1835
|
const registered = await heartbeat(originUrl, { claim: true });
|
|
1598
1836
|
console.log(
|
|
1599
1837
|
registered
|
|
1600
|
-
? `
|
|
1601
|
-
: `
|
|
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`,
|
|
1602
1840
|
);
|
|
1603
1841
|
hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
|
|
1604
1842
|
}
|
|
1605
1843
|
|
|
1844
|
+
if (configOnly) {
|
|
1845
|
+
sf.write({ state: "ready" });
|
|
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
|
+
);
|
|
1851
|
+
void startDevSession();
|
|
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
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1606
1868
|
let announced = false;
|
|
1607
1869
|
const viteOutTee = logSink.source();
|
|
1608
1870
|
const viteErrTee = logSink.source();
|
|
1609
|
-
child
|
|
1871
|
+
child?.stdout.on("data", (chunk) => {
|
|
1610
1872
|
const text = chunk.toString();
|
|
1611
1873
|
process.stdout.write(text);
|
|
1612
1874
|
viteOutTee(chunk);
|
|
@@ -1616,23 +1878,23 @@ async function dev() {
|
|
|
1616
1878
|
if (!announced && /localhost:\d+/.test(text.replace(ANSI_RE, ""))) {
|
|
1617
1879
|
announced = true;
|
|
1618
1880
|
sf.write({ state: "ready" });
|
|
1619
|
-
console.log(`data:
|
|
1881
|
+
console.log(`data: live — this session reads and writes the app\'s real records`);
|
|
1620
1882
|
console.log(`ready: http://localhost:${port}`);
|
|
1621
1883
|
void startDevSession();
|
|
1622
1884
|
}
|
|
1623
1885
|
});
|
|
1624
1886
|
// vite stderr is where build errors and the SDK's browser-error mirror
|
|
1625
1887
|
// land — piped (was inherit) so `monty logs` sees them too.
|
|
1626
|
-
child
|
|
1888
|
+
child?.stderr.on("data", (chunk) => {
|
|
1627
1889
|
process.stderr.write(chunk);
|
|
1628
1890
|
viteErrTee(chunk);
|
|
1629
1891
|
});
|
|
1630
|
-
child
|
|
1892
|
+
child?.on("error", (e) => {
|
|
1631
1893
|
void endSession().then(() => {
|
|
1632
1894
|
fail("VITE_SPAWN_FAILED", `Could not start vite: ${e?.message ?? e}. Run \`monty install\`, then retry.`);
|
|
1633
1895
|
});
|
|
1634
1896
|
});
|
|
1635
|
-
child
|
|
1897
|
+
child?.on("exit", (code) => {
|
|
1636
1898
|
viteOutTee.flush();
|
|
1637
1899
|
viteErrTee.flush();
|
|
1638
1900
|
void endSession().then(() => process.exit(code ?? 0));
|
|
@@ -1806,7 +2068,7 @@ async function waitForDns(hostname) {
|
|
|
1806
2068
|
|
|
1807
2069
|
// Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
|
|
1808
2070
|
// binary on first use). Resolves with the public URL, or null on failure —
|
|
1809
|
-
//
|
|
2071
|
+
// the session then falls back to localhost-only registration. onOutput receives
|
|
1810
2072
|
// every chunk (both fds) for the dev.log tee.
|
|
1811
2073
|
function startTunnel(port, onUrlChange, onOutput) {
|
|
1812
2074
|
return new Promise((resolve) => {
|
|
@@ -1881,11 +2143,237 @@ function resolveComponent(name) {
|
|
|
1881
2143
|
return name;
|
|
1882
2144
|
}
|
|
1883
2145
|
|
|
2146
|
+
// ── monty add page <name> ──────────────────────────────────────────────────
|
|
2147
|
+
// Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
|
|
2148
|
+
// use (config-only apps gain src/ + vite from the template — their
|
|
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
|
+
|
|
2198
|
+
async function addPage(appDir, pageName) {
|
|
2199
|
+
if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
|
|
2200
|
+
fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
|
|
2201
|
+
}
|
|
2202
|
+
const configPath = join(appDir, "monty.config.ts");
|
|
2203
|
+
if (!existsSync(configPath)) {
|
|
2204
|
+
fail("NO_CONFIG", `No monty.config.ts in ${appDir} — run this inside a Monty app.`);
|
|
2205
|
+
}
|
|
2206
|
+
const config = readFileSync(configPath, "utf8");
|
|
2207
|
+
const appName = config.match(/name: "([^"]*)"/)?.[1] ?? pageName;
|
|
2208
|
+
const routeFile = join(appDir, "src", "routes", `${pageName}.tsx`);
|
|
2209
|
+
if (existsSync(routeFile)) {
|
|
2210
|
+
fail("PAGE_EXISTS", `src/routes/${pageName}.tsx already exists. Edit it, or pick a different page name.`);
|
|
2211
|
+
}
|
|
2212
|
+
if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
|
|
2213
|
+
console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
|
|
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
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// First custom page on a config-only app: bring in the SPA scaffold.
|
|
2226
|
+
if (isConfigOnlyApp(appDir)) {
|
|
2227
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
2228
|
+
const templateDir = [
|
|
2229
|
+
join(pkgRoot, "template"),
|
|
2230
|
+
join(pkgRoot, "..", "template"),
|
|
2231
|
+
].find((d) => existsSync(join(d, "monty.config.ts")));
|
|
2232
|
+
if (!templateDir) {
|
|
2233
|
+
fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
|
|
2234
|
+
}
|
|
2235
|
+
console.log(`add page: upgrading config-only app with the SPA scaffold`);
|
|
2236
|
+
cpSync(templateDir, appDir, {
|
|
2237
|
+
recursive: true,
|
|
2238
|
+
force: false, // existing files (config, AGENTS.md, package.json…) win
|
|
2239
|
+
filter: (src) => {
|
|
2240
|
+
const base = basename(src);
|
|
2241
|
+
if (["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base)) return false;
|
|
2242
|
+
// The app keeps its own identity files.
|
|
2243
|
+
if (["monty.config.ts", "AGENTS.md", "CLAUDE.md"].includes(base)) return false;
|
|
2244
|
+
return true;
|
|
2245
|
+
},
|
|
2246
|
+
});
|
|
2247
|
+
// gitignore ships name-mangled in the published bundle (see create()).
|
|
2248
|
+
if (existsSync(join(appDir, "gitignore")) && !existsSync(join(appDir, ".gitignore"))) {
|
|
2249
|
+
renameSync(join(appDir, "gitignore"), join(appDir, ".gitignore"));
|
|
2250
|
+
}
|
|
2251
|
+
// Merge the template's SPA deps/scripts into the app's package.json —
|
|
2252
|
+
// the config-only one has only sdk+zod and no scripts.
|
|
2253
|
+
const tplPkg = JSON.parse(readFileSync(join(templateDir, "package.json"), "utf8"));
|
|
2254
|
+
const pkgPath = join(appDir, "package.json");
|
|
2255
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
2256
|
+
pkg.scripts = { ...tplPkg.scripts, ...pkg.scripts };
|
|
2257
|
+
pkg.dependencies = { ...tplPkg.dependencies, ...pkg.dependencies };
|
|
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;
|
|
2262
|
+
// The published template pins the sdk; a workspace app may carry
|
|
2263
|
+
// workspace:* — the merge above keeps the app's existing pin either way.
|
|
2264
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
2265
|
+
const htmlPath = join(appDir, "index.html");
|
|
2266
|
+
if (existsSync(htmlPath)) {
|
|
2267
|
+
writeFileSync(htmlPath, readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${appName}</title>`));
|
|
2268
|
+
}
|
|
2269
|
+
// Client env for the SPA half, same as create().
|
|
2270
|
+
if (!existsSync(join(appDir, ".env.local"))) {
|
|
2271
|
+
try {
|
|
2272
|
+
const { host } = loadConfig();
|
|
2273
|
+
const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
|
|
2274
|
+
writeFileSync(join(appDir, ".env.local"), `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`);
|
|
2275
|
+
console.log("config: .env.local written");
|
|
2276
|
+
} catch {
|
|
2277
|
+
console.log("config: WARNING — could not fetch client config; copy .env.example to .env.local manually");
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
// The template's starter index route assumes it IS the app — for a page
|
|
2281
|
+
// upgrade the shell owns the app; drop the starter so the page below is
|
|
2282
|
+
// the only route beside __root.
|
|
2283
|
+
const starter = join(appDir, "src", "routes", "index.tsx");
|
|
2284
|
+
if (existsSync(starter)) rmSync(starter);
|
|
2285
|
+
appendFileSync(
|
|
2286
|
+
join(appDir, "AGENTS.md"),
|
|
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`,
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
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
|
+
|
|
2307
|
+
// The page route: a real, working start — SDK data hooks, shell-aware.
|
|
2308
|
+
mkdirSync(dirname(routeFile), { recursive: true });
|
|
2309
|
+
const pageTitle = (pageName[0].toUpperCase() + pageName.slice(1)).replace(/-/g, " ");
|
|
2310
|
+
writeFileSync(routeFile, `import { createFileRoute } from "@tanstack/react-router";
|
|
2311
|
+
import { PageHeader } from "@montytools/sdk/ui";
|
|
2312
|
+
|
|
2313
|
+
export const Route = createFileRoute("/${pageName}")({
|
|
2314
|
+
component: ${pageComponentName(pageName)},
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
// A CUSTOM page: bespoke UI mounted inside the platform shell at
|
|
2318
|
+
// /apps/<slug>/${pageName}. Data comes from @montytools/sdk hooks
|
|
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).
|
|
2322
|
+
function ${pageComponentName(pageName)}() {
|
|
2323
|
+
return (
|
|
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>
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
`);
|
|
2335
|
+
console.log(`page: src/routes/${pageName}.tsx`);
|
|
2336
|
+
|
|
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}" } }`);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
console.log(`added: custom page "${pageName}"`);
|
|
2361
|
+
console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function pageComponentName(pageName) {
|
|
2365
|
+
return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
|
|
2366
|
+
}
|
|
2367
|
+
|
|
1884
2368
|
async function add() {
|
|
1885
2369
|
const appDir = requireAppDir("add");
|
|
1886
2370
|
const names = rest.filter((a) => !a.startsWith("--"));
|
|
1887
2371
|
if (names.length === 0) {
|
|
1888
|
-
fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available.");
|
|
2372
|
+
fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available (or `monty add page <name>` for a custom page).");
|
|
2373
|
+
}
|
|
2374
|
+
if (names[0] === "page") {
|
|
2375
|
+
await addPage(appDir, names[1]);
|
|
2376
|
+
return;
|
|
1889
2377
|
}
|
|
1890
2378
|
const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
|
|
1891
2379
|
|
|
@@ -1960,7 +2448,12 @@ async function docs() {
|
|
|
1960
2448
|
// readable back. Read from the arg, then a TTY prompt, then stdin (piping).
|
|
1961
2449
|
async function secret() {
|
|
1962
2450
|
const appDir = requireAppDir("secret");
|
|
1963
|
-
|
|
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);
|
|
1964
2457
|
const config = loadConfig();
|
|
1965
2458
|
if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
1966
2459
|
const [sub, name] = rest.filter((a) => !a.startsWith("-"));
|
|
@@ -1997,13 +2490,25 @@ async function secret() {
|
|
|
1997
2490
|
console.log(del ? `secret: removed ${name} from ${meta.slug}` : `secret: set ${name} on ${meta.slug} (write-only; not readable back)`);
|
|
1998
2491
|
}
|
|
1999
2492
|
|
|
2000
|
-
// ── 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.
|
|
2001
2498
|
async function deploy() {
|
|
2002
|
-
const appDir = requireAppDir(
|
|
2499
|
+
const appDir = requireAppDir(command);
|
|
2003
2500
|
ensureSdk(appDir);
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
}
|
|
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
|
+
});
|
|
2007
2512
|
const config = loadConfig();
|
|
2008
2513
|
if (!config?.key) {
|
|
2009
2514
|
fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
|
|
@@ -2014,17 +2519,40 @@ async function deploy() {
|
|
|
2014
2519
|
console.log("compile: monty.config.ts");
|
|
2015
2520
|
const meta = await compileConfig(appDir);
|
|
2016
2521
|
console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
|
|
2017
|
-
|
|
2018
|
-
//
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
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
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
|
|
2539
|
+
// the platform shell renders the app.
|
|
2540
|
+
const configOnly = isConfigOnlyApp(appDir);
|
|
2541
|
+
if (!configOnly) {
|
|
2542
|
+
// 2) Fail fast locally before any upload. Build FIRST — it also generates
|
|
2543
|
+
// src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
|
|
2544
|
+
run(appDir, "build", ["npx", "vite", "build"],
|
|
2545
|
+
"The production build failed. Read the vite error above; it names the file to fix.");
|
|
2546
|
+
run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
|
|
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) {
|
|
2549
|
+
fail("MANIFEST_MISSING",
|
|
2550
|
+
"This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
|
|
2551
|
+
}
|
|
2024
2552
|
|
|
2025
2553
|
// 3) Multipart POST to the host.
|
|
2026
2554
|
const dist = join(appDir, "dist");
|
|
2027
|
-
const files = walk(dist);
|
|
2555
|
+
const files = configOnly ? [] : walk(dist);
|
|
2028
2556
|
const buildFile = join(appDir, ".monty", "build");
|
|
2029
2557
|
if (existsSync(buildFile)) {
|
|
2030
2558
|
meta.buildId = readFileSync(buildFile, "utf8").trim();
|
|
@@ -2053,9 +2581,15 @@ async function deploy() {
|
|
|
2053
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.`);
|
|
2054
2582
|
}
|
|
2055
2583
|
}
|
|
2056
|
-
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;
|
|
2057
2588
|
form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
|
|
2058
|
-
|
|
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(" + ")}`);
|
|
2059
2593
|
if (publicFns.length > 0) {
|
|
2060
2594
|
console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
|
|
2061
2595
|
}
|
|
@@ -2063,6 +2597,16 @@ async function deploy() {
|
|
|
2063
2597
|
console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
|
|
2064
2598
|
}
|
|
2065
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
|
+
}
|
|
2066
2610
|
// 3b) SOURCE snapshot rides every publish. Without it the platform keeps
|
|
2067
2611
|
// only the minified bundle and the sole copy of the app's code is this
|
|
2068
2612
|
// folder — delete it and the source is gone forever. The snapshot is what
|
|
@@ -2072,16 +2616,23 @@ async function deploy() {
|
|
|
2072
2616
|
{
|
|
2073
2617
|
const packed = packSource(appDir);
|
|
2074
2618
|
if (packed === null) {
|
|
2075
|
-
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.");
|
|
2076
2620
|
} else if (packed.tooLarge) {
|
|
2077
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.");
|
|
2078
2622
|
} else {
|
|
2079
2623
|
sourceHash = packed.hash;
|
|
2080
2624
|
meta.sourceHash = sourceHash;
|
|
2081
2625
|
form.set("source", new Blob([packed.buf]), "source.tar.gz");
|
|
2082
|
-
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})`);
|
|
2083
2627
|
}
|
|
2084
2628
|
}
|
|
2629
|
+
// V2 schema CAS: prove which stored manifest this checkout last synced,
|
|
2630
|
+
// so an API/MCP edit made meanwhile surfaces as MANIFEST_DRIFT instead of
|
|
2631
|
+
// being clobbered (fix: `monty schema pull`).
|
|
2632
|
+
if (meta.manifest !== undefined) {
|
|
2633
|
+
const state = readSchemaState(appDir);
|
|
2634
|
+
if (state?.hash) meta.baseManifestHash = state.hash;
|
|
2635
|
+
}
|
|
2085
2636
|
form.set("monty", JSON.stringify(meta));
|
|
2086
2637
|
let total = 0;
|
|
2087
2638
|
for (const file of files) {
|
|
@@ -2090,7 +2641,11 @@ async function deploy() {
|
|
|
2090
2641
|
total += buf.byteLength;
|
|
2091
2642
|
form.set(rel, new Blob([buf]), rel);
|
|
2092
2643
|
}
|
|
2093
|
-
console.log(
|
|
2644
|
+
console.log(
|
|
2645
|
+
configOnly
|
|
2646
|
+
? `upload: config-only (manifest, no bundle) -> ${config.host}/api/deploy`
|
|
2647
|
+
: `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
|
|
2648
|
+
);
|
|
2094
2649
|
const res = await fetch(`${config.host}/api/deploy`, {
|
|
2095
2650
|
method: "POST",
|
|
2096
2651
|
headers: { authorization: `Bearer ${config.key}` },
|
|
@@ -2098,10 +2653,22 @@ async function deploy() {
|
|
|
2098
2653
|
});
|
|
2099
2654
|
const body = await res.json().catch(() => null);
|
|
2100
2655
|
if (!res.ok || !body?.ok) {
|
|
2656
|
+
if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
|
|
2657
|
+
console.log(`schema drift (remote changes):\n${body.summary}`);
|
|
2658
|
+
}
|
|
2101
2659
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
|
|
2102
2660
|
}
|
|
2103
2661
|
console.log(`origin: ${body.origin}`);
|
|
2104
|
-
console.log(`
|
|
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.
|
|
2670
|
+
writeSchemaState(appDir, manifestHash(meta.manifest));
|
|
2671
|
+
}
|
|
2105
2672
|
// Stamp what was published — pull uses this to tell "unchanged since last
|
|
2106
2673
|
// sync" from "locally modified".
|
|
2107
2674
|
if (sourceHash) {
|
|
@@ -2110,15 +2677,22 @@ async function deploy() {
|
|
|
2110
2677
|
JSON.stringify({ hash: sourceHash, syncedAt: Date.now() }) + "\n",
|
|
2111
2678
|
);
|
|
2112
2679
|
}
|
|
2680
|
+
saveFailNote = null;
|
|
2681
|
+
saveJson({ status: "saved" });
|
|
2113
2682
|
}
|
|
2114
2683
|
|
|
2115
|
-
// Bundle server
|
|
2116
|
-
//
|
|
2117
|
-
//
|
|
2118
|
-
//
|
|
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.
|
|
2119
2690
|
async function bundleServerFns(appDir, schedule) {
|
|
2120
2691
|
const serverEntry = join(appDir, "server", "index.ts");
|
|
2121
|
-
|
|
2692
|
+
const datasetsEntry = join(appDir, "datasets", "index.ts");
|
|
2693
|
+
const hasServer = existsSync(serverEntry);
|
|
2694
|
+
const hasDatasets = existsSync(datasetsEntry);
|
|
2695
|
+
if (!hasServer && !hasDatasets) return null;
|
|
2122
2696
|
const { build } = await import("esbuild");
|
|
2123
2697
|
const tmpDir = join(appDir, ".monty");
|
|
2124
2698
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -2128,9 +2702,10 @@ async function bundleServerFns(appDir, schedule) {
|
|
|
2128
2702
|
// hands back only the matching cron expression, so the worker needs the
|
|
2129
2703
|
// expression→fn mapping at runtime.
|
|
2130
2704
|
writeFileSync(entry, [
|
|
2131
|
-
`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 = {};`,
|
|
2132
2707
|
`import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
|
|
2133
|
-
`export default makeFnWorker(appFns, { schedule: ${JSON.stringify(schedule ?? {})} });`,
|
|
2708
|
+
`export default makeFnWorker({ ...appFns, ...appDatasets }, { schedule: ${JSON.stringify(schedule ?? {})} });`,
|
|
2134
2709
|
].join("\n"));
|
|
2135
2710
|
// Fail the deploy if server code reaches for Node built-ins — a Worker
|
|
2136
2711
|
// can't run them, and a silent runtime crash on Live is the worst outcome.
|
|
@@ -2159,15 +2734,20 @@ async function bundleServerFns(appDir, schedule) {
|
|
|
2159
2734
|
logLevel: "silent",
|
|
2160
2735
|
plugins: [banPlatformImports],
|
|
2161
2736
|
});
|
|
2162
|
-
fns = discoverFnExports(serverEntry);
|
|
2163
|
-
|
|
2164
|
-
|
|
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.`);
|
|
2742
|
+
}
|
|
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.");
|
|
2165
2745
|
}
|
|
2166
|
-
return { code: readFileSync(out, "utf8"), fns };
|
|
2746
|
+
return { code: readFileSync(out, "utf8"), fns, datasets };
|
|
2167
2747
|
} catch (e) {
|
|
2168
|
-
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
|
|
2169
2749
|
const msg = e?.errors?.[0]?.text ?? e?.message ?? String(e);
|
|
2170
|
-
fail("FN_BUNDLE_FAILED", `Could not bundle server
|
|
2750
|
+
fail("FN_BUNDLE_FAILED", `Could not bundle the app's server code: ${msg}`);
|
|
2171
2751
|
} finally {
|
|
2172
2752
|
rmSync(entry, { force: true });
|
|
2173
2753
|
rmSync(out, { force: true });
|
|
@@ -2191,10 +2771,12 @@ function discoverFnExports(serverEntry) {
|
|
|
2191
2771
|
}
|
|
2192
2772
|
|
|
2193
2773
|
// Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
|
|
2194
|
-
//
|
|
2774
|
+
// the session heartbeat's last good schema through transient config breakage.
|
|
2195
2775
|
async function compileConfig(appDir, { soft = false } = {}) {
|
|
2196
2776
|
try {
|
|
2197
|
-
|
|
2777
|
+
// Config-only apps (no SPA) always compile a manifest: the platform
|
|
2778
|
+
// shell is their only renderer.
|
|
2779
|
+
return await compileAppConfig(appDir, { forceManifest: isConfigOnlyApp(appDir) });
|
|
2198
2780
|
} catch (e) {
|
|
2199
2781
|
if (e instanceof CompileError) {
|
|
2200
2782
|
if (soft) {
|
|
@@ -2228,7 +2810,7 @@ function walk(dir) {
|
|
|
2228
2810
|
return out;
|
|
2229
2811
|
}
|
|
2230
2812
|
|
|
2231
|
-
// ── cron matching (the
|
|
2813
|
+
// ── cron matching (the session's cron ticker in `monty dev`) ─────────────
|
|
2232
2814
|
// UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
|
|
2233
2815
|
// names (JAN, MON). Deliberately forgiving: an unparsable field simply never
|
|
2234
2816
|
// matches locally — Cloudflare is the syntax authority at deploy, so a bad
|
|
@@ -2289,15 +2871,14 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
|
|
|
2289
2871
|
// terminal — no browser, no dev session. Auth is the mk_ key exchanged at
|
|
2290
2872
|
// /api/dev-token for a 5-minute workspace token (member lane, org_id from
|
|
2291
2873
|
// the verified JWT), then the 6 public records functions over Convex's HTTP
|
|
2292
|
-
// API. `app`
|
|
2293
|
-
//
|
|
2294
|
-
// 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.
|
|
2295
2876
|
|
|
2296
2877
|
const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
|
|
2297
2878
|
|
|
2298
2879
|
// rest, minus flags AND their values — `monty data list leads --app crm`
|
|
2299
|
-
// must not read "crm" as a positional. Boolean flags
|
|
2300
|
-
//
|
|
2880
|
+
// must not read "crm" as a positional. Boolean flags have no value and
|
|
2881
|
+
// are skipped alone.
|
|
2301
2882
|
function dataPositionals() {
|
|
2302
2883
|
const out = [];
|
|
2303
2884
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -2331,7 +2912,10 @@ function resolveDataApp() {
|
|
|
2331
2912
|
if (!slug) {
|
|
2332
2913
|
fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
|
|
2333
2914
|
}
|
|
2334
|
-
|
|
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;
|
|
2335
2919
|
}
|
|
2336
2920
|
|
|
2337
2921
|
// mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
|
|
@@ -2398,7 +2982,7 @@ function flattenRow(doc) {
|
|
|
2398
2982
|
}
|
|
2399
2983
|
|
|
2400
2984
|
function dataUsage() {
|
|
2401
|
-
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");
|
|
2402
2986
|
console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
|
|
2403
2987
|
console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
|
|
2404
2988
|
console.log(" get <table> <id>");
|
|
@@ -2406,7 +2990,7 @@ function dataUsage() {
|
|
|
2406
2990
|
console.log(" update <table> <id> --data '<json>' [--unset field,field]");
|
|
2407
2991
|
console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
|
|
2408
2992
|
console.log(" remove <table> <id>");
|
|
2409
|
-
console.log("target: --app <slug> (or run inside the app folder)
|
|
2993
|
+
console.log("target: --app <slug> (or run inside the app folder)");
|
|
2410
2994
|
process.exit(1);
|
|
2411
2995
|
}
|
|
2412
2996
|
|
|
@@ -2552,6 +3136,101 @@ if (command !== "dev" && command !== "logs") {
|
|
|
2552
3136
|
installSkills({ appDir: findAppRoot(process.cwd()) });
|
|
2553
3137
|
}
|
|
2554
3138
|
|
|
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.
|
|
3146
|
+
async function schemaCmd() {
|
|
3147
|
+
const verb = rest[0];
|
|
3148
|
+
const { host, key } = loadConfig() ?? {};
|
|
3149
|
+
if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
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;
|
|
3183
|
+
try {
|
|
3184
|
+
manifest = JSON.parse(raw);
|
|
3185
|
+
} catch {
|
|
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?");
|
|
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;
|
|
3213
|
+
}
|
|
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}` },
|
|
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));
|
|
3232
|
+
}
|
|
3233
|
+
|
|
2555
3234
|
switch (command) {
|
|
2556
3235
|
case "login":
|
|
2557
3236
|
await login();
|
|
@@ -2563,7 +3242,7 @@ switch (command) {
|
|
|
2563
3242
|
await pull();
|
|
2564
3243
|
break;
|
|
2565
3244
|
case "commit":
|
|
2566
|
-
|
|
3245
|
+
fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
|
|
2567
3246
|
break;
|
|
2568
3247
|
case "log":
|
|
2569
3248
|
case "versions":
|
|
@@ -2595,7 +3274,7 @@ switch (command) {
|
|
|
2595
3274
|
apps();
|
|
2596
3275
|
break;
|
|
2597
3276
|
case "skills":
|
|
2598
|
-
installSkills({ appDir: findAppRoot(process.cwd()), silent: false });
|
|
3277
|
+
installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
|
|
2599
3278
|
console.log("skills: up to date");
|
|
2600
3279
|
break;
|
|
2601
3280
|
case "install":
|
|
@@ -2607,25 +3286,28 @@ switch (command) {
|
|
|
2607
3286
|
case "typecheck":
|
|
2608
3287
|
typecheckApp();
|
|
2609
3288
|
break;
|
|
3289
|
+
case "save":
|
|
2610
3290
|
case "deploy":
|
|
2611
3291
|
await deploy();
|
|
2612
3292
|
break;
|
|
2613
3293
|
case "data":
|
|
2614
3294
|
await data();
|
|
2615
3295
|
break;
|
|
3296
|
+
case "schema":
|
|
3297
|
+
await schemaCmd();
|
|
3298
|
+
break;
|
|
2616
3299
|
case "secret":
|
|
2617
3300
|
await secret();
|
|
2618
3301
|
break;
|
|
2619
3302
|
default:
|
|
2620
|
-
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>");
|
|
2621
3304
|
console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
|
|
2622
|
-
console.log(" create <slug> [--name N] [--icon I] [--
|
|
3305
|
+
console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
|
|
2623
3306
|
console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
|
|
2624
|
-
console.log("
|
|
2625
|
-
console.log("
|
|
2626
|
-
console.log("
|
|
2627
|
-
console.log("
|
|
2628
|
-
console.log(" add <name...> install curated UI components (see `monty components`)");
|
|
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)");
|
|
3310
|
+
console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
|
|
2629
3311
|
console.log(" components [query] list the curated component catalog");
|
|
2630
3312
|
console.log(" docs <name> view a component's source before installing");
|
|
2631
3313
|
console.log(" current which app folder am I in?");
|
|
@@ -2634,8 +3316,10 @@ switch (command) {
|
|
|
2634
3316
|
console.log(" install install app dependencies");
|
|
2635
3317
|
console.log(" build production build (vite, via monty)");
|
|
2636
3318
|
console.log(" typecheck typecheck (builds first if needed)");
|
|
2637
|
-
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");
|
|
2638
3321
|
console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
|
|
3322
|
+
console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
|
|
2639
3323
|
console.log(" skills install/refresh the agent build skill");
|
|
2640
3324
|
process.exit(command ? 1 : 0);
|
|
2641
3325
|
}
|