@montytools/cli 0.5.4 → 0.5.6
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 +606 -677
- package/lib/compile.mjs +3 -3
- package/lib/schemaCodegen.mjs +7 -3
- package/lib/schemaPull.mjs +36 -50
- package/lib/styleLint.mjs +175 -0
- package/lib/tokens-manifest.mjs +90 -0
- package/lib/views.mjs +19 -0
- package/package.json +3 -3
- package/skills/monty-build/SKILL.md +92 -105
- package/skills/monty-design/SKILL.md +104 -55
- package/template/AGENTS.md +53 -33
- package/template/index.html +5 -5
- package/template/package.json +2 -2
- package/template/src/components/ui/badge.tsx +1 -1
- package/template/src/components/ui/button.tsx +7 -7
- package/template/src/components/ui/card.tsx +3 -3
- package/template/src/components/ui/empty.tsx +3 -3
- package/template/src/components/ui/field.tsx +5 -5
- package/template/src/components/ui/input.tsx +1 -1
- package/template/src/components/ui/label.tsx +1 -1
- package/template/src/components/ui/select.tsx +4 -4
- package/template/src/components/ui/table.tsx +3 -3
- package/template/src/index.css +7 -118
- package/template/src/main.tsx +1 -1
- package/template/src/monty.gen.ts +16 -0
- package/template/src/routes/index.tsx +1 -1
- package/template/tsconfig.json +1 -1
- package/template/monty.config.ts +0 -21
package/bin/monty.mjs
CHANGED
|
@@ -15,16 +15,14 @@ import { basename, dirname, join, relative, resolve } from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { createInterface } from "node:readline/promises";
|
|
17
17
|
import { CATALOG, REGISTRIES } from "./catalog.mjs";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseViewSort, validateViewColumns } from "../lib/views.mjs";
|
|
18
|
+
import { readSchemaState, schemaPull, writeGenModule, writeSchemaState } from "../lib/schemaPull.mjs";
|
|
19
|
+
import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseKanbanFlag, parseViewSort, validateViewColumns } from "../lib/views.mjs";
|
|
20
|
+
import { formatViolations, lintStyles } from "../lib/styleLint.mjs";
|
|
22
21
|
|
|
23
22
|
// MONTY_HOME overrides the state root (default ~/.monty): config.json,
|
|
24
23
|
// apps/, and desktop.json all live under it. This is how a second, isolated
|
|
25
24
|
// Monty state coexists on one machine — the desktop's dev channel points it
|
|
26
|
-
// at ~/.monty-dev so platform development never touches the real state.
|
|
27
|
-
// custom root is a sandbox: the legacy visible home (~/Monty) is not scanned.
|
|
25
|
+
// at ~/.monty-dev so platform development never touches the real state.
|
|
28
26
|
const CONFIG_DIR = process.env.MONTY_HOME
|
|
29
27
|
? resolve(process.env.MONTY_HOME)
|
|
30
28
|
: join(homedir(), ".monty");
|
|
@@ -50,11 +48,9 @@ const ANSI_RE = new RegExp(
|
|
|
50
48
|
// Every app's source lives in one predictable, hidden place: `monty create`
|
|
51
49
|
// registers the app first and the server-minted id names the folder
|
|
52
50
|
// (~/.monty/apps/<id>) — id-keyed because slugs may be renamed later; the
|
|
53
|
-
// `id` stamped into monty.
|
|
54
|
-
// provisions the home; --dir overrides per create.
|
|
55
|
-
// visible home (~/Monty) keep working — every scan reads both.
|
|
51
|
+
// `id` stamped into .monty/app.json is the durable identity. `monty login`
|
|
52
|
+
// provisions the home; --dir overrides per create.
|
|
56
53
|
const MONTY_HOME = join(CONFIG_DIR, "apps");
|
|
57
|
-
const LEGACY_MONTY_HOME = join(homedir(), "Monty");
|
|
58
54
|
|
|
59
55
|
const [, , command, ...rest] = process.argv;
|
|
60
56
|
|
|
@@ -116,22 +112,16 @@ function findMontyrcHost(startDir) {
|
|
|
116
112
|
return null;
|
|
117
113
|
}
|
|
118
114
|
|
|
119
|
-
// Reads config.json
|
|
120
|
-
// next login) or { defaultHost, profiles: { [host]: { key } } }.
|
|
115
|
+
// Reads config.json: { defaultHost, profiles: { [host]: { key } } }.
|
|
121
116
|
function normalizedConfig() {
|
|
122
117
|
let raw = null;
|
|
123
118
|
try {
|
|
124
119
|
raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
125
120
|
} catch { /* not logged in anywhere yet */ }
|
|
126
|
-
if (!raw
|
|
127
|
-
|
|
128
|
-
return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
|
|
121
|
+
if (!raw?.profiles || typeof raw.profiles !== "object") {
|
|
122
|
+
return { defaultHost: null, profiles: {} };
|
|
129
123
|
}
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
defaultHost: legacyHost,
|
|
133
|
-
profiles: raw.key ? { [legacyHost]: { key: raw.key } } : {},
|
|
134
|
-
};
|
|
124
|
+
return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
|
|
135
125
|
}
|
|
136
126
|
|
|
137
127
|
function resolveHost() {
|
|
@@ -344,11 +334,9 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
344
334
|
}
|
|
345
335
|
}
|
|
346
336
|
|
|
347
|
-
// ── monty current
|
|
348
|
-
// Folder management users never think about: every app lives in ~/Monty,
|
|
349
|
-
// `current` says where you are, `select` prints the folder for cd $(...).
|
|
337
|
+
// ── monty current ──────────────────────────────────────────────────────────
|
|
350
338
|
// The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
|
|
351
|
-
// create and
|
|
339
|
+
// create and connect) or, transitionally, monty.config.ts.
|
|
352
340
|
function isAppRoot(dir) {
|
|
353
341
|
return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
|
|
354
342
|
}
|
|
@@ -363,7 +351,7 @@ function findAppRoot(start) {
|
|
|
363
351
|
}
|
|
364
352
|
}
|
|
365
353
|
|
|
366
|
-
// The identity stamp: { id, slug, name, icon
|
|
354
|
+
// The identity stamp: { id, slug, name, icon } — the app's
|
|
367
355
|
// durable identity on this machine, independent of the config file.
|
|
368
356
|
function readAppJson(dir) {
|
|
369
357
|
try {
|
|
@@ -400,58 +388,15 @@ function readIdFromConfig(dir) {
|
|
|
400
388
|
}
|
|
401
389
|
}
|
|
402
390
|
|
|
403
|
-
function scanAppsHome(root) {
|
|
404
|
-
if (!existsSync(root)) return [];
|
|
405
|
-
return readdirSync(root)
|
|
406
|
-
.map((name) => join(root, name))
|
|
407
|
-
.filter((p) => isAppRoot(p))
|
|
408
|
-
.map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function listLocalApps() {
|
|
412
|
-
// A MONTY_HOME sandbox lists only its own apps — leaking the legacy home
|
|
413
|
-
// into an isolated root would defeat the isolation.
|
|
414
|
-
return process.env.MONTY_HOME
|
|
415
|
-
? scanAppsHome(MONTY_HOME)
|
|
416
|
-
: [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
|
|
417
|
-
}
|
|
418
|
-
|
|
419
391
|
function current() {
|
|
420
392
|
const root = findAppRoot(process.cwd());
|
|
421
393
|
if (!root) {
|
|
422
|
-
fail("NOT_IN_APP",
|
|
394
|
+
fail("NOT_IN_APP", "You are not inside a Monty app. cd into the app's folder (`monty connect <slug>` pulls a copy into any folder).");
|
|
423
395
|
}
|
|
424
396
|
console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
|
|
425
397
|
const id = readIdFromConfig(root);
|
|
426
398
|
if (id) console.log(`id: ${id}`);
|
|
427
399
|
console.log(`path: ${root}`);
|
|
428
|
-
if (!root.startsWith(MONTY_HOME) && !root.startsWith(LEGACY_MONTY_HOME)) {
|
|
429
|
-
console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function select() {
|
|
434
|
-
const slug = rest.find((a) => !a.startsWith("--"));
|
|
435
|
-
if (!slug) {
|
|
436
|
-
fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
|
|
437
|
-
}
|
|
438
|
-
const apps = listLocalApps();
|
|
439
|
-
const hit = apps.find((a) => a.slug === slug || a.id === slug || basename(a.path) === slug);
|
|
440
|
-
if (!hit) {
|
|
441
|
-
const known = apps.map((a) => a.slug).join(", ") || "(none)";
|
|
442
|
-
fail("APP_NOT_LOCAL", `No local source for "${slug}" on this machine. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
|
|
443
|
-
}
|
|
444
|
-
// Bare path on stdout so command substitution works: cd "$(monty select x)"
|
|
445
|
-
console.log(hit.path);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function apps() {
|
|
449
|
-
const local = listLocalApps();
|
|
450
|
-
if (!local.length) {
|
|
451
|
-
console.log(`no local apps in ${MONTY_HOME} — create one with \`monty create <slug>\``);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
for (const a of local) console.log(`${a.slug}\t${a.path}`);
|
|
455
400
|
}
|
|
456
401
|
|
|
457
402
|
// Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
|
|
@@ -488,7 +433,7 @@ function readSlug(appDir) {
|
|
|
488
433
|
// (Not `monty logs` — that tails the dev shell.)
|
|
489
434
|
async function versionsLog() {
|
|
490
435
|
const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
|
|
491
|
-
if (!slug) fail("NO_SLUG", "Usage: monty
|
|
436
|
+
if (!slug) fail("NO_SLUG", "Usage: monty history [slug] — or run it inside an app folder.");
|
|
492
437
|
const { host, key } = loadConfig();
|
|
493
438
|
if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
494
439
|
const res = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
|
|
@@ -531,7 +476,7 @@ async function pull() {
|
|
|
531
476
|
}
|
|
532
477
|
const app = appsBody.apps.find((a) => a.slug === slug);
|
|
533
478
|
if (!app) {
|
|
534
|
-
fail("APP_NOT_FOUND", `No app "${slug}" in this workspace
|
|
479
|
+
fail("APP_NOT_FOUND", `No app "${slug}" in this workspace — check the app list at ${host}.`);
|
|
535
480
|
}
|
|
536
481
|
// --version <hash-prefix>: restore a specific snapshot from `monty log`
|
|
537
482
|
// instead of the newest one. Prefixes resolve against the history list.
|
|
@@ -548,7 +493,7 @@ async function pull() {
|
|
|
548
493
|
}
|
|
549
494
|
const matches = vbody.versions.filter((v) => v.hash.startsWith(versionFlag));
|
|
550
495
|
if (matches.length === 0) {
|
|
551
|
-
fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty
|
|
496
|
+
fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty history ${slug}\` lists what exists.`);
|
|
552
497
|
}
|
|
553
498
|
if (matches.length > 1) {
|
|
554
499
|
fail("VERSION_AMBIGUOUS", `"${versionFlag}" matches ${matches.length} versions — use more characters of the hash.`);
|
|
@@ -558,7 +503,10 @@ async function pull() {
|
|
|
558
503
|
} else if (!app.sourceHash) {
|
|
559
504
|
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.`);
|
|
560
505
|
}
|
|
561
|
-
|
|
506
|
+
if (!app.id) {
|
|
507
|
+
fail("APP_ID_MISSING", `"${slug}" has no app id in the workspace registry — save it once from a machine that has its source, then pull works.`);
|
|
508
|
+
}
|
|
509
|
+
const target = join(MONTY_HOME, app.id);
|
|
562
510
|
if (existsSync(target) && !rest.includes("--force")) {
|
|
563
511
|
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.`);
|
|
564
512
|
}
|
|
@@ -620,30 +568,184 @@ async function pull() {
|
|
|
620
568
|
console.log("next: `monty install`, then `monty dev`.");
|
|
621
569
|
}
|
|
622
570
|
|
|
571
|
+
// ── monty connect ──────────────────────────────────────────────────────────
|
|
572
|
+
// The one command that turns ANY folder into a working copy of a cloud app:
|
|
573
|
+
// `monty connect <slug> [dir]` (dir defaults to the current folder) pulls
|
|
574
|
+
// the app's files, installs dependencies, stamps identity, and registers
|
|
575
|
+
// the checkout so the desktop's picker sees it. Apps without a source
|
|
576
|
+
// snapshot but with a manifest get the config-only scaffold synced from the
|
|
577
|
+
// registry. Multiple copies of one app on a machine are fine.
|
|
578
|
+
async function connect() {
|
|
579
|
+
const [slugArg, dirArg] = rest.filter((a) => !a.startsWith("--"));
|
|
580
|
+
if (!slugArg || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slugArg)) {
|
|
581
|
+
fail("INVALID_SLUG", "Usage: monty connect <slug> [dir] — pulls the app into dir (default: the current folder, which must be empty).");
|
|
582
|
+
}
|
|
583
|
+
const slug = slugArg;
|
|
584
|
+
const { host, key } = loadConfig();
|
|
585
|
+
if (!key) {
|
|
586
|
+
fail("NOT_LOGGED_IN", `Connecting needs your workspace (${host}). Run \`monty login\` first.`);
|
|
587
|
+
}
|
|
588
|
+
const appsRes = await fetch(`${host}/api/apps`, { headers: { authorization: `Bearer ${key}` } });
|
|
589
|
+
const appsBody = await appsRes.json().catch(() => null);
|
|
590
|
+
if (!appsRes.ok || !appsBody?.ok) {
|
|
591
|
+
fail(appsBody?.code ?? `HTTP_${appsRes.status}`, appsBody?.fix ?? "Could not list workspace apps — check the connection and `monty login`.");
|
|
592
|
+
}
|
|
593
|
+
const app = appsBody.apps.find((a) => a.slug === slug);
|
|
594
|
+
if (!app) {
|
|
595
|
+
fail("APP_NOT_FOUND", `No app "${slug}" in this workspace — check the app list at ${host}.`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const target = resolve(process.cwd(), dirArg ?? ".");
|
|
599
|
+
if (existsSync(target)) {
|
|
600
|
+
if (!statSync(target).isDirectory()) {
|
|
601
|
+
fail("NOT_A_DIRECTORY", `${target} is a file. Point \`monty connect\` at a folder.`);
|
|
602
|
+
}
|
|
603
|
+
if (readdirSync(target).filter((n) => n !== ".DS_Store").length > 0) {
|
|
604
|
+
// A folder that already holds THIS app needs nothing — the working
|
|
605
|
+
// copy is untouched.
|
|
606
|
+
const stamp = readAppJson(target);
|
|
607
|
+
if (stamp && ((app.id && stamp.id === app.id) || stamp.slug === slug)) {
|
|
608
|
+
console.log(`connected: ${slug} -> ${target} (already a copy of this app)`);
|
|
609
|
+
console.log("next: `monty dev`");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
fail("DIR_NOT_EMPTY", `${target} is not empty. Connect pulls the app's files INTO the folder — run it in an empty one, or name a new folder: \`monty connect ${slug} ${slug}\`.`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (app.sourceHash) {
|
|
617
|
+
console.log(`connect: ${slug} <- ${host}`);
|
|
618
|
+
const res = await fetch(`${host}/api/source?slug=${slug}`, {
|
|
619
|
+
headers: { authorization: `Bearer ${key}` },
|
|
620
|
+
});
|
|
621
|
+
if (!res.ok) {
|
|
622
|
+
const b = await res.json().catch(() => null);
|
|
623
|
+
fail(b?.code ?? `HTTP_${res.status}`, b?.fix ?? "Downloading the snapshot failed — retry.");
|
|
624
|
+
}
|
|
625
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
626
|
+
const hash = createHash("sha256").update(buf).digest("hex");
|
|
627
|
+
if (hash !== app.sourceHash) {
|
|
628
|
+
fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
|
|
629
|
+
}
|
|
630
|
+
// Extract into a sibling staging folder first — a failed extract never
|
|
631
|
+
// leaves a half-written copy. An existing (empty) target keeps its
|
|
632
|
+
// inode: staging contents MOVE in, so a shell sitting in it stays sane.
|
|
633
|
+
const staging = `${target}.connect-tmp`;
|
|
634
|
+
rmSync(staging, { recursive: true, force: true });
|
|
635
|
+
mkdirSync(staging, { recursive: true });
|
|
636
|
+
const tarFile = join(staging, ".source.tar.gz");
|
|
637
|
+
writeFileSync(tarFile, buf);
|
|
638
|
+
const untar = spawnSync("tar", ["-xzf", tarFile, "-C", staging], { stdio: "pipe" });
|
|
639
|
+
rmSync(tarFile, { force: true });
|
|
640
|
+
if (untar.status !== 0) {
|
|
641
|
+
rmSync(staging, { recursive: true, force: true });
|
|
642
|
+
fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
|
|
643
|
+
}
|
|
644
|
+
if (existsSync(target)) {
|
|
645
|
+
for (const name of readdirSync(staging)) renameSync(join(staging, name), join(target, name));
|
|
646
|
+
rmSync(staging, { recursive: true, force: true });
|
|
647
|
+
} else {
|
|
648
|
+
renameSync(staging, target);
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
|
|
652
|
+
writeFileSync(
|
|
653
|
+
join(target, ".env.local"),
|
|
654
|
+
`VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
|
|
655
|
+
);
|
|
656
|
+
} catch {
|
|
657
|
+
console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
|
|
658
|
+
}
|
|
659
|
+
mkdirSync(join(target, ".monty"), { recursive: true });
|
|
660
|
+
writeFileSync(
|
|
661
|
+
join(target, ".monty", "source.json"),
|
|
662
|
+
JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
|
|
663
|
+
);
|
|
664
|
+
} else {
|
|
665
|
+
// No snapshot: the app's config in the workspace IS the app — a
|
|
666
|
+
// config-only scaffold synced from it is a complete working copy.
|
|
667
|
+
console.log(`connect: ${slug} <- ${host} (config-only — the workspace holds the app's config)`);
|
|
668
|
+
if (typeof app.id !== "string" || !/^[a-z0-9]{10,64}$/i.test(app.id)) {
|
|
669
|
+
fail("APP_ID_MISSING", `"${slug}" has no usable app id in the workspace registry — save it once from a machine that has it, then connect works.`);
|
|
670
|
+
}
|
|
671
|
+
mkdirSync(target, { recursive: true });
|
|
672
|
+
writeConfigOnlyScaffold(target, { appId: app.id, slug, name: app.name, icon: app.icon });
|
|
673
|
+
console.log("config: the workspace holds this app's config — read it with `monty schema`");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// The snapshot excludes .monty/ — the workspace row is the authority for
|
|
677
|
+
// identity, so stamp it fresh.
|
|
678
|
+
writeAppJson(target, {
|
|
679
|
+
...(app.id ? { id: app.id } : {}),
|
|
680
|
+
slug,
|
|
681
|
+
...(app.name ? { name: app.name } : {}),
|
|
682
|
+
...(app.icon ? { icon: app.icon } : {}),
|
|
683
|
+
});
|
|
684
|
+
if (existsSync(join(target, "package.json"))) {
|
|
685
|
+
installDepsIn(target);
|
|
686
|
+
}
|
|
687
|
+
console.log(`connected: ${slug} -> ${target}`);
|
|
688
|
+
console.log(target === process.cwd() ? "next: `monty dev`" : `next: cd ${dirArg ?? target} && monty dev`);
|
|
689
|
+
}
|
|
690
|
+
|
|
623
691
|
// ── monty create ───────────────────────────────────────────────────────────
|
|
624
|
-
// A CONFIG-ONLY app: no SPA at all —
|
|
625
|
-
// the platform shell renders it. The presence of index.html is the
|
|
626
|
-
// (every SPA template ships one;
|
|
692
|
+
// A CONFIG-ONLY app: no SPA at all — the workspace config is the whole app
|
|
693
|
+
// and the platform shell renders it. The presence of index.html is the
|
|
694
|
+
// marker (every SPA template ships one; config-only folders never do).
|
|
627
695
|
function isConfigOnlyApp(appDir) {
|
|
628
696
|
return !existsSync(join(appDir, "index.html"));
|
|
629
697
|
}
|
|
630
698
|
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
699
|
+
// One-time migration off the config-as-code shape: monty.config.ts stops
|
|
700
|
+
// existing in app folders — the workspace owns the config and
|
|
701
|
+
// src/monty.gen.ts is its generated, never-hand-edited mirror. Runs at the
|
|
702
|
+
// front of `monty dev` / `monty save` / `monty schema pull`: generates the
|
|
703
|
+
// mirror, rewrites src imports onto it, and retires the config as a .bak.
|
|
704
|
+
// Demo folders are exempt (the demo rail compiles their config).
|
|
705
|
+
async function migrateConfigToGen(appDir, { host, key, slug }) {
|
|
706
|
+
const configPath = join(appDir, "monty.config.ts");
|
|
707
|
+
if (!existsSync(configPath) || existsSync(join(appDir, "demo.json"))) return false;
|
|
708
|
+
const srcDir = join(appDir, "src");
|
|
709
|
+
if (existsSync(srcDir)) {
|
|
710
|
+
if (!key) {
|
|
711
|
+
console.log("migrate: monty.config.ts is retired, but generating src/monty.gen.ts needs the workspace — run `monty login`, then any monty command migrates this folder.");
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
const res = await fetch(`${host}/api/schema?slug=${encodeURIComponent(slug)}`, {
|
|
715
|
+
headers: { authorization: `Bearer ${key}` },
|
|
716
|
+
}).catch(() => null);
|
|
717
|
+
const body = await res?.json().catch(() => null);
|
|
718
|
+
if (!res?.ok || !body?.ok) {
|
|
719
|
+
console.log("migrate: could not fetch the workspace config — monty.config.ts left in place this run (edits to it never land; `monty schema` is the config).");
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
writeGenModule(appDir, body.manifest ?? { slug, tables: {} }, { name: body.name, icon: body.icon });
|
|
723
|
+
if (body.hash) writeSchemaState(appDir, body.hash);
|
|
724
|
+
rewriteGenImports(srcDir);
|
|
725
|
+
}
|
|
726
|
+
renameSync(configPath, `${configPath}.bak`);
|
|
727
|
+
console.log("migrated: monty.config.ts retired (kept as monty.config.ts.bak). The workspace owns the config — read it with `monty schema`, change it with `monty schema set`; src/monty.gen.ts mirrors it automatically.");
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Point every `monty.config` import in src/ at the generated module. The
|
|
732
|
+
// specifier is rewritten relative to each importing file (main.tsx imports
|
|
733
|
+
// "./monty.gen", a route imports "../monty.gen").
|
|
734
|
+
function rewriteGenImports(srcDir) {
|
|
735
|
+
const files = walk(srcDir).filter((f) => /\.(ts|tsx)$/.test(f));
|
|
736
|
+
for (const file of files) {
|
|
737
|
+
const src = readFileSync(file, "utf8");
|
|
738
|
+
if (!src.includes("monty.config")) continue;
|
|
739
|
+
let rel = relative(dirname(file), join(srcDir, "monty.gen")).replace(/\\/g, "/");
|
|
740
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
741
|
+
const out = src.replace(
|
|
742
|
+
/(["'])(?:\.\.?\/)+monty\.config(?:\.ts)?\1/g,
|
|
743
|
+
(_m, q) => `${q}${rel}${q}`,
|
|
744
|
+
);
|
|
745
|
+
if (out !== src) {
|
|
746
|
+
writeFileSync(file, out);
|
|
747
|
+
console.log(`migrate: ${relative(dirname(srcDir), file)} now imports the generated module`);
|
|
748
|
+
}
|
|
647
749
|
}
|
|
648
750
|
}
|
|
649
751
|
|
|
@@ -665,14 +767,15 @@ function discoverPages(appDir) {
|
|
|
665
767
|
|
|
666
768
|
const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
|
|
667
769
|
|
|
668
|
-
The app is rendered by the Monty platform from its
|
|
669
|
-
tables (zod), derived fields (\`rollup\`/\`lookup
|
|
670
|
-
\`settings\`, and \`pages\`. There is no src/, no
|
|
770
|
+
The app is rendered by the Monty platform from its config stored in the
|
|
771
|
+
workspace — tables (zod-shaped), derived fields (\`rollup\`/\`lookup\`/
|
|
772
|
+
\`formula\`), \`metrics\`, \`settings\`, and \`pages\`. There is no src/, no
|
|
773
|
+
React, no build — and no config file here: the workspace copy is the only
|
|
774
|
+
one.
|
|
671
775
|
|
|
672
|
-
- The
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
NOT change a workspace-owned app's schema.
|
|
776
|
+
- The config's door is the schema API: read it with \`monty schema\`,
|
|
777
|
+
change it with \`monty schema set '<json>'\` (or pipe:
|
|
778
|
+
\`monty schema set -\`) — validated, CAS-guarded, live within seconds.
|
|
676
779
|
- Formulas are strings in the Monty expression grammar, e.g.
|
|
677
780
|
\`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
|
|
678
781
|
ABOVE the formula and \`metrics.<name>\` are in scope.
|
|
@@ -682,42 +785,19 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
|
|
|
682
785
|
system record page cannot express. If the page is still one table, start
|
|
683
786
|
with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
|
|
684
787
|
with the record controls from \`@montytools/sdk/ui\`.
|
|
685
|
-
- Need a bespoke page later? \`monty add
|
|
686
|
-
app with a SPA scaffold
|
|
788
|
+
- Need a bespoke page later? \`monty page add\` declares it and upgrades this
|
|
789
|
+
app with a SPA scaffold (typed against the generated src/monty.gen.ts
|
|
790
|
+
mirror); \`monty save\` ships the code.
|
|
687
791
|
`;
|
|
688
792
|
|
|
793
|
+
// A config-only folder is identity + instructions, nothing more: the
|
|
794
|
+
// workspace holds the app, so there is no config file, no package.json, no
|
|
795
|
+
// tsconfig — nothing that even looks editable.
|
|
689
796
|
function writeConfigOnlyScaffold(target, { appId, slug, name, icon }) {
|
|
690
797
|
mkdirSync(target, { recursive: true });
|
|
691
|
-
writeFileSync(join(target, "monty.config.ts"), `import { defineApp } from "@montytools/sdk";
|
|
692
|
-
|
|
693
|
-
// This file IS the app: tables, derived fields, metrics, settings, pages.
|
|
694
|
-
// The Monty platform renders it — no src/, no build. Declare tables as zod
|
|
695
|
-
// objects; derive with rollup()/lookup()/formula(); see AGENTS.md.
|
|
696
|
-
export const app = defineApp({
|
|
697
|
-
id: "${appId}",
|
|
698
|
-
slug: "${slug}",
|
|
699
|
-
name: "${name}",
|
|
700
|
-
icon: "${icon}",
|
|
701
|
-
tables: {},
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
export type App = typeof app;
|
|
705
|
-
`);
|
|
706
|
-
writeFileSync(join(target, "package.json"), JSON.stringify({
|
|
707
|
-
name: slug,
|
|
708
|
-
private: true,
|
|
709
|
-
type: "module",
|
|
710
|
-
dependencies: { "@montytools/sdk": "latest", zod: "^4.4.3" },
|
|
711
|
-
}, null, 2) + "\n");
|
|
712
|
-
writeFileSync(join(target, "tsconfig.json"), JSON.stringify({
|
|
713
|
-
compilerOptions: {
|
|
714
|
-
target: "ES2022", module: "ESNext", moduleResolution: "bundler",
|
|
715
|
-
strict: true, skipLibCheck: true, noEmit: true,
|
|
716
|
-
},
|
|
717
|
-
include: ["monty.config.ts"],
|
|
718
|
-
}, null, 2) + "\n");
|
|
719
798
|
writeFileSync(join(target, ".gitignore"), "node_modules/\n.monty/\n");
|
|
720
799
|
writeFileSync(join(target, "AGENTS.md"), CONFIG_ONLY_AGENTS_MD);
|
|
800
|
+
writeAppJson(target, { id: appId, slug, name, icon });
|
|
721
801
|
}
|
|
722
802
|
|
|
723
803
|
async function create() {
|
|
@@ -725,13 +805,6 @@ async function create() {
|
|
|
725
805
|
if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
|
726
806
|
fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens, max 64 chars (e.g. "standup-notes").');
|
|
727
807
|
}
|
|
728
|
-
// An app with this slug already on disk means create is the wrong verb —
|
|
729
|
-
// fail BEFORE registering, and never suggest deleting anything: the folder
|
|
730
|
-
// may hold real, uncommitted work.
|
|
731
|
-
const dupe = listLocalApps().find((a) => a.slug === slug);
|
|
732
|
-
if (dupe) {
|
|
733
|
-
fail("APP_EXISTS", `"${slug}" already exists on this machine at ${dupe.path}. Keep working on it there (cd "$(monty select ${slug})"); pick a different slug for a new app.`);
|
|
734
|
-
}
|
|
735
808
|
const name =
|
|
736
809
|
flag("name") ??
|
|
737
810
|
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
@@ -777,15 +850,12 @@ async function create() {
|
|
|
777
850
|
mkdirSync(dirname(target), { recursive: true });
|
|
778
851
|
|
|
779
852
|
// DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
|
|
780
|
-
// platform shell renders it; \`monty add
|
|
853
|
+
// platform shell renders it; \`monty page add\` scaffolds a SPA the moment a
|
|
781
854
|
// bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
|
|
782
855
|
// (\`--config-only\` stays accepted as the now-default no-op).
|
|
783
856
|
if (!rest.includes("--spa")) {
|
|
784
|
-
console.log(`create: ${slug} -> ${target} (config-only)`);
|
|
857
|
+
console.log(`create: ${slug} -> ${target} (config-only — the workspace holds the config)`);
|
|
785
858
|
writeConfigOnlyScaffold(target, { appId, slug, name, icon });
|
|
786
|
-
// The identity stamp is the app-root marker and identity source from
|
|
787
|
-
// here on — the config file is just code.
|
|
788
|
-
writeAppJson(target, { id: appId, slug, name, icon });
|
|
789
859
|
// The user's brief lands at the top of AGENTS.md, same as SPA creates.
|
|
790
860
|
const brief = flag("description");
|
|
791
861
|
if (brief?.trim()) {
|
|
@@ -817,7 +887,7 @@ async function create() {
|
|
|
817
887
|
}
|
|
818
888
|
installSkills({ appDir: target });
|
|
819
889
|
console.log(`created: ${target}`);
|
|
820
|
-
console.log(`next: cd ${target} && monty
|
|
890
|
+
console.log(`next: cd ${target} && monty dev — then \`monty schema set\` declares the tables`);
|
|
821
891
|
return;
|
|
822
892
|
}
|
|
823
893
|
|
|
@@ -827,7 +897,7 @@ async function create() {
|
|
|
827
897
|
const templateDir = [
|
|
828
898
|
join(pkgRoot, "template"),
|
|
829
899
|
join(pkgRoot, "..", "template"),
|
|
830
|
-
].find((d) => existsSync(join(d, "
|
|
900
|
+
].find((d) => existsSync(join(d, "src", "main.tsx")));
|
|
831
901
|
if (!templateDir) {
|
|
832
902
|
fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
|
|
833
903
|
}
|
|
@@ -854,16 +924,10 @@ async function create() {
|
|
|
854
924
|
writeFileSync(gitignorePath, "node_modules/\ndist/\n.monty/\n.env.local\n.env\n");
|
|
855
925
|
}
|
|
856
926
|
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
configPath,
|
|
862
|
-
readFileSync(configPath, "utf8")
|
|
863
|
-
.replace(/^([ \t]*)slug: "[^"]*"/m, `$1id: "${appId}",\n$1slug: "${slug}"`)
|
|
864
|
-
.replace(/name: "[^"]*"/, `name: "${name}"`)
|
|
865
|
-
.replace(/icon: "[^"]*"/, `icon: "${icon}"`),
|
|
866
|
-
);
|
|
927
|
+
// Identity into the copied files. The registry landed the empty config at
|
|
928
|
+
// registration; the generated mirror derives from it — the template's
|
|
929
|
+
// placeholder src/monty.gen.ts is replaced wholesale.
|
|
930
|
+
writeGenModule(target, { slug, tables: {} }, { name, icon });
|
|
867
931
|
const pkgPath = join(target, "package.json");
|
|
868
932
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
869
933
|
pkg.name = slug;
|
|
@@ -910,7 +974,7 @@ async function create() {
|
|
|
910
974
|
}
|
|
911
975
|
|
|
912
976
|
// Build tracking: the /new screen minted an id; recording it here lets
|
|
913
|
-
// `monty
|
|
977
|
+
// `monty save` resolve it and flips the UI into "agent is working" mode.
|
|
914
978
|
const buildId = flag("build");
|
|
915
979
|
if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
|
|
916
980
|
mkdirSync(join(target, ".monty"), { recursive: true });
|
|
@@ -937,20 +1001,35 @@ async function create() {
|
|
|
937
1001
|
// The full app lifecycle goes through the CLI — agents never invoke pnpm,
|
|
938
1002
|
// vite, or tsc directly. Same underlying tools, agent-shaped output, and the
|
|
939
1003
|
// build-before-typecheck ordering handled for you.
|
|
940
|
-
function
|
|
941
|
-
const appDir = requireAppDir("install");
|
|
1004
|
+
function installDepsIn(appDir) {
|
|
942
1005
|
const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
|
|
943
1006
|
run(appDir, "install", [pm, "install"],
|
|
944
1007
|
"Dependency install failed. Read the package manager error above; usually network or a bad package.json edit.");
|
|
945
1008
|
ensureSdk(appDir);
|
|
946
1009
|
}
|
|
947
1010
|
|
|
1011
|
+
function installDeps() {
|
|
1012
|
+
installDepsIn(requireAppDir("install"));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
948
1015
|
function buildApp() {
|
|
949
1016
|
const appDir = requireAppDir("build");
|
|
950
1017
|
run(appDir, "build", ["npx", "vite", "build"],
|
|
951
1018
|
"The production build failed. Read the vite error above; it names the file to fix.");
|
|
952
1019
|
}
|
|
953
1020
|
|
|
1021
|
+
// `monty style` — the token-vocabulary lint, standalone. The same check
|
|
1022
|
+
// runs advisory at `monty dev` and blocking inside `monty save`.
|
|
1023
|
+
async function styleCheck() {
|
|
1024
|
+
const appDir = requireAppDir("style");
|
|
1025
|
+
const violations = await lintStyles(appDir);
|
|
1026
|
+
if (violations.length === 0) {
|
|
1027
|
+
console.log("style: clean — every class is on the token vocabulary");
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
fail("STYLE_OFF_TOKENS", `${violations.length} off-token style${violations.length === 1 ? "" : "s"}:\n${await formatViolations(violations)}\nFix with the monty-design skill's vocabulary; a deliberate exception takes \`// monty-style-ignore\` on its line.`);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
954
1033
|
function typecheckApp() {
|
|
955
1034
|
const appDir = requireAppDir("typecheck");
|
|
956
1035
|
// routeTree.gen.ts is generated by the build — without it tsc fails on a
|
|
@@ -991,7 +1070,7 @@ async function freePort(start) {
|
|
|
991
1070
|
// Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
|
|
992
1071
|
// minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
|
|
993
1072
|
// SDK's vite plugin) and upgrades the app automatically before dev/deploy.
|
|
994
|
-
const MIN_SDK = "0.2.
|
|
1073
|
+
const MIN_SDK = "0.2.6";
|
|
995
1074
|
const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
|
|
996
1075
|
|
|
997
1076
|
function installedSdkVersion(appDir) {
|
|
@@ -1451,21 +1530,29 @@ async function dev() {
|
|
|
1451
1530
|
|
|
1452
1531
|
installSkills({ appDir });
|
|
1453
1532
|
ensureSdk(appDir);
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1533
|
+
// Style advisory (non-blocking here; `monty save` enforces): put off-token
|
|
1534
|
+
// styling in the terminal the agent is watching, before the session opens.
|
|
1535
|
+
try {
|
|
1536
|
+
const styleViolations = await lintStyles(appDir);
|
|
1537
|
+
if (styleViolations.length > 0) {
|
|
1538
|
+
console.log(`style: ${styleViolations.length} off-token style${styleViolations.length === 1 ? "" : "s"} — \`monty style\` lists them; \`monty save\` refuses them`);
|
|
1539
|
+
}
|
|
1540
|
+
} catch {}
|
|
1541
|
+
// Identity comes from the .monty/app.json stamp (written by create and
|
|
1542
|
+
// connect) — the platform reads NOTHING from the config file, which is
|
|
1543
|
+
// just code the bundle imports. Clock work is an `every` rule now — the
|
|
1544
|
+
// platform's minute tick dispatches to this session's runtime over the
|
|
1545
|
+
// tunnel like any rule; the session runs no clock of its own.
|
|
1460
1546
|
const stamp = readAppJson(appDir);
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1547
|
+
if (typeof stamp?.slug !== "string" || !stamp.slug) {
|
|
1548
|
+
fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here to join an existing app, or `monty create <slug>` for a new one.");
|
|
1549
|
+
}
|
|
1550
|
+
const meta = { slug: stamp.slug, name: stamp.name, icon: stamp.icon };
|
|
1465
1551
|
const cfg = loadConfig();
|
|
1466
1552
|
const host = cfg?.host ?? DEFAULT_HOST;
|
|
1467
|
-
|
|
1468
|
-
//
|
|
1553
|
+
await migrateConfigToGen(appDir, { host, key: cfg?.key, slug: meta.slug });
|
|
1554
|
+
// CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is heartbeats
|
|
1555
|
+
// only — the platform shell renders the app from the workspace config.
|
|
1469
1556
|
const configOnly = isConfigOnlyApp(appDir);
|
|
1470
1557
|
// Auto-pick a free port (agents run several apps side by side); an
|
|
1471
1558
|
// explicit --port is honored strictly.
|
|
@@ -1484,34 +1571,23 @@ async function dev() {
|
|
|
1484
1571
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1485
1572
|
});
|
|
1486
1573
|
} else {
|
|
1487
|
-
console.log(`dev: config-only app "${meta.slug}" — no vite;
|
|
1574
|
+
console.log(`dev: config-only app "${meta.slug}" — no vite; the workspace owns the config (read it with \`monty schema\`)`);
|
|
1488
1575
|
}
|
|
1489
1576
|
|
|
1490
1577
|
let tunnelChild = null;
|
|
1491
1578
|
let hbTimer = null;
|
|
1492
1579
|
let touchTimer = null;
|
|
1493
|
-
let cronTimer = null;
|
|
1494
1580
|
let ended = false;
|
|
1495
1581
|
let registeredOnce = false;
|
|
1496
|
-
//
|
|
1497
|
-
let driftAnnounced = false;
|
|
1498
|
-
// The registry-owned notice prints once per session.
|
|
1582
|
+
// The schema-lives-in-the-workspace notice prints once per session.
|
|
1499
1583
|
let configIgnoredAnnounced = false;
|
|
1500
1584
|
const devStartedAt = Date.now();
|
|
1501
1585
|
const sessionId = `dev_${randomBytes(16).toString("hex")}`;
|
|
1502
1586
|
const buildFile = join(appDir, ".monty", "build");
|
|
1503
1587
|
const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
|
|
1504
|
-
// The session schema channel (manifest-less apps): heartbeats carry the
|
|
1505
|
-
// compiled schema, and edits to monty.config.ts are re-compiled (softly)
|
|
1506
|
-
// so schema changes reach the platform within one heartbeat.
|
|
1507
|
-
// Registry-owned apps skip all of it — their schema lives behind the
|
|
1508
|
-
// doors, and the local config copy follows the registry (auto-pull below).
|
|
1509
|
-
let currentMeta = meta;
|
|
1510
1588
|
// Once per registry change: the manifest hash we last tried to sync the
|
|
1511
1589
|
// local config copy to (successful or refused — never loop on dirty).
|
|
1512
1590
|
let syncAttemptedHash = null;
|
|
1513
|
-
const configPath = join(appDir, "monty.config.ts");
|
|
1514
|
-
let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
|
|
1515
1591
|
|
|
1516
1592
|
// Advertise this session. The touch timer (not the platform heartbeat,
|
|
1517
1593
|
// which starts minutes late or never when logged out) keeps updatedAt
|
|
@@ -1522,7 +1598,7 @@ async function dev() {
|
|
|
1522
1598
|
version: 1,
|
|
1523
1599
|
cli: CLI_VERSION,
|
|
1524
1600
|
pid: process.pid,
|
|
1525
|
-
vitePid: child
|
|
1601
|
+
vitePid: child?.pid ?? null,
|
|
1526
1602
|
tunnelPid: null,
|
|
1527
1603
|
port,
|
|
1528
1604
|
slug: meta.slug,
|
|
@@ -1544,57 +1620,6 @@ async function dev() {
|
|
|
1544
1620
|
});
|
|
1545
1621
|
touchTimer = setInterval(() => sf.write({}), DEV_JSON_TOUCH_MS);
|
|
1546
1622
|
|
|
1547
|
-
// The session cron runner: the Live counterpart is a real Cloudflare Cron
|
|
1548
|
-
// Trigger on the app's fn-worker; here the CLI matches monty.config.ts
|
|
1549
|
-
// `schedule` entries against the UTC clock once per minute and invokes the
|
|
1550
|
-
// fn through the same /__monty/fn runtime (x-monty-schedule marks the
|
|
1551
|
-
// lane, so ctx.viewer matches Live exactly). Config edits hot-apply via
|
|
1552
|
-
// currentMeta. Fire-and-forget: a failing cron fn prints its instruction
|
|
1553
|
-
// here and never blocks the loop.
|
|
1554
|
-
let lastCronMinute = null;
|
|
1555
|
-
function cronTick() {
|
|
1556
|
-
const sched = currentMeta?.schedule;
|
|
1557
|
-
if (!sched || !loggedIn) return;
|
|
1558
|
-
const now = new Date();
|
|
1559
|
-
const minute = Math.floor(now.getTime() / 60_000);
|
|
1560
|
-
if (minute === lastCronMinute) return;
|
|
1561
|
-
lastCronMinute = minute;
|
|
1562
|
-
for (const [fn, expr] of Object.entries(sched)) {
|
|
1563
|
-
if (!cronMatches(expr, now)) continue;
|
|
1564
|
-
console.log(`cron: "${expr}" → ${fn}() (UTC)`);
|
|
1565
|
-
const t0 = Date.now();
|
|
1566
|
-
fetch(`http://localhost:${port}/__monty/fn/${fn}`, {
|
|
1567
|
-
method: "POST",
|
|
1568
|
-
headers: { "content-type": "application/json", "x-monty-schedule": expr },
|
|
1569
|
-
body: "{}",
|
|
1570
|
-
}).then(async (r) => {
|
|
1571
|
-
if (r.ok) {
|
|
1572
|
-
console.log(`cron: ${fn} ok (${Date.now() - t0}ms)`);
|
|
1573
|
-
} else {
|
|
1574
|
-
const e = await r.json().catch(() => null);
|
|
1575
|
-
console.log(`cron: ${fn} failed [${e?.code ?? r.status}] ${e?.fix ?? ""}`);
|
|
1576
|
-
}
|
|
1577
|
-
}).catch((e) => console.log(`cron: ${fn} unreachable — ${e?.message ?? e}`));
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
cronTimer = setInterval(cronTick, 20_000);
|
|
1581
|
-
|
|
1582
|
-
async function refreshSchemaIfChanged() {
|
|
1583
|
-
if (registryOwned) return; // the doors own the schema; nothing to push
|
|
1584
|
-
try {
|
|
1585
|
-
const m = statSync(configPath).mtimeMs;
|
|
1586
|
-
if (m === configMtime) return;
|
|
1587
|
-
configMtime = m;
|
|
1588
|
-
const fresh = await compileConfig(appDir, { soft: true });
|
|
1589
|
-
if (fresh) {
|
|
1590
|
-
currentMeta = fresh;
|
|
1591
|
-
console.log(
|
|
1592
|
-
`schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
|
|
1593
|
-
);
|
|
1594
|
-
}
|
|
1595
|
-
} catch { /* transient fs hiccup — next beat retries */ }
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
1623
|
async function clearDevSession(timeoutMs = 2000) {
|
|
1599
1624
|
if (cfg?.key) {
|
|
1600
1625
|
try {
|
|
@@ -1613,7 +1638,6 @@ async function dev() {
|
|
|
1613
1638
|
ended = true;
|
|
1614
1639
|
if (hbTimer) clearInterval(hbTimer);
|
|
1615
1640
|
if (touchTimer) clearInterval(touchTimer);
|
|
1616
|
-
if (cronTimer) clearInterval(cronTimer);
|
|
1617
1641
|
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
1618
1642
|
// vite is a direct child (no npx wrapper), so this actually kills it —
|
|
1619
1643
|
// a bare SIGTERM from the desktop must never orphan vite on the port.
|
|
@@ -1628,7 +1652,6 @@ async function dev() {
|
|
|
1628
1652
|
ended = true;
|
|
1629
1653
|
if (hbTimer) clearInterval(hbTimer);
|
|
1630
1654
|
if (touchTimer) clearInterval(touchTimer);
|
|
1631
|
-
if (cronTimer) clearInterval(cronTimer);
|
|
1632
1655
|
try { tunnelChild?.kill(); } catch { /* already gone */ }
|
|
1633
1656
|
try { child?.kill(); } catch { /* already gone */ }
|
|
1634
1657
|
console.log(`dev-session: superseded — ${fix}`);
|
|
@@ -1639,7 +1662,6 @@ async function dev() {
|
|
|
1639
1662
|
|
|
1640
1663
|
async function heartbeat(originUrl, { claim = false } = {}) {
|
|
1641
1664
|
if (ended) return false; // shutdown already ran — no side effects
|
|
1642
|
-
await refreshSchemaIfChanged();
|
|
1643
1665
|
try {
|
|
1644
1666
|
// Re-read the key EVERY beat: the desktop (or a fresh `monty login`)
|
|
1645
1667
|
// may have replaced an expired key while this session runs — the
|
|
@@ -1659,23 +1681,9 @@ async function dev() {
|
|
|
1659
1681
|
// owner. BOUNDED so a never-registering session (broken network)
|
|
1660
1682
|
// can't steal the lock from a newer active session forever.
|
|
1661
1683
|
claim: claim || (!registeredOnce && Date.now() - devStartedAt < 90_000),
|
|
1662
|
-
name:
|
|
1663
|
-
icon:
|
|
1684
|
+
name: meta.name,
|
|
1685
|
+
icon: meta.icon,
|
|
1664
1686
|
buildId,
|
|
1665
|
-
schemaJson: currentMeta.schemaJson,
|
|
1666
|
-
// App Manifest v2 (docs/manifest-v2.md) — present only for V2
|
|
1667
|
-
// configs; lands only for manifest-less apps (the doors own the
|
|
1668
|
-
// rest).
|
|
1669
|
-
manifest: currentMeta.manifest,
|
|
1670
|
-
// The CAS base for the manifest-less landing (see `monty schema
|
|
1671
|
-
// pull`).
|
|
1672
|
-
baseManifestHash:
|
|
1673
|
-
currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
|
|
1674
|
-
// The expose block rides the same compile as the schema — the dev
|
|
1675
|
-
// visitor preview of a manifest-less app is gated on it.
|
|
1676
|
-
exposure: currentMeta.exposure,
|
|
1677
|
-
// Rules ride the same channel (manifest-less apps only).
|
|
1678
|
-
rules: currentMeta.rules,
|
|
1679
1687
|
}),
|
|
1680
1688
|
signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
|
|
1681
1689
|
});
|
|
@@ -1685,11 +1693,6 @@ async function dev() {
|
|
|
1685
1693
|
stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
|
|
1686
1694
|
return false;
|
|
1687
1695
|
}
|
|
1688
|
-
if (data?.code === "MANIFEST_DRIFT") {
|
|
1689
|
-
// Remote schema edits (another agent, via the API) — the terminal
|
|
1690
|
-
// the building agent is watching gets the fix, invariant #4.
|
|
1691
|
-
console.log(`schema drift (remote changes):${data.summary ? `\n${data.summary}` : ""}`);
|
|
1692
|
-
}
|
|
1693
1696
|
console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
|
|
1694
1697
|
// A dead key is a SIGNED-OUT session — advertise it so the desktop
|
|
1695
1698
|
// (which owns the session) can surface sign-in instead of letting
|
|
@@ -1699,76 +1702,48 @@ async function dev() {
|
|
|
1699
1702
|
}
|
|
1700
1703
|
return false;
|
|
1701
1704
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
driftAnnounced = true;
|
|
1707
|
-
console.log(`schema drift (remote changes):${data.manifestDrift.summary ? `\n${data.manifestDrift.summary}` : ""}`);
|
|
1708
|
-
console.log(`config push held: ${data.manifestDrift.fix ?? "Run `monty schema pull`, merge, then save again."}`);
|
|
1709
|
-
}
|
|
1710
|
-
} else if (currentMeta.manifest !== undefined) {
|
|
1711
|
-
driftAnnounced = false;
|
|
1712
|
-
// This beat's manifest landed LIVE — the new CAS base.
|
|
1713
|
-
try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
|
|
1714
|
-
}
|
|
1705
|
+
// publicFns rides every beat: dev.json mirrors the door-owned
|
|
1706
|
+
// allowlist so the vite runtime's /__monty/public gate follows a
|
|
1707
|
+
// `monty public set` / MCP change within one heartbeat.
|
|
1708
|
+
const beatExtras = Array.isArray(data?.publicFns) ? { publicFns: data.publicFns } : {};
|
|
1715
1709
|
if (!registeredOnce) {
|
|
1716
1710
|
registeredOnce = true;
|
|
1717
|
-
sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1711
|
+
sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now(), ...beatExtras });
|
|
1718
1712
|
} else {
|
|
1719
|
-
sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1713
|
+
sf.write({ loggedIn: true, lastHeartbeatAt: Date.now(), ...beatExtras });
|
|
1720
1714
|
}
|
|
1721
|
-
if (
|
|
1715
|
+
if (!configIgnoredAnnounced) {
|
|
1722
1716
|
configIgnoredAnnounced = true;
|
|
1723
|
-
console.log("schema: this app's
|
|
1724
|
-
// Stamp registry ownership: from the next session on, the config
|
|
1725
|
-
// compile is skipped entirely.
|
|
1726
|
-
try {
|
|
1727
|
-
writeAppJson(appDir, {
|
|
1728
|
-
registryOwned: true,
|
|
1729
|
-
slug: meta.slug,
|
|
1730
|
-
...(currentMeta.name ? { name: currentMeta.name } : {}),
|
|
1731
|
-
...(currentMeta.icon ? { icon: currentMeta.icon } : {}),
|
|
1732
|
-
});
|
|
1733
|
-
} catch { /* the stamp is a convenience; the beat decided */ }
|
|
1717
|
+
console.log("schema: this app's config lives in the workspace. Read it: `monty schema`; change it: `monty schema set '<json>'` (or pipe: `monty schema set -`). src/monty.gen.ts mirrors it automatically — never edit that file.");
|
|
1734
1718
|
}
|
|
1735
|
-
//
|
|
1736
|
-
// moved (another editor, the schema door)
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1719
|
+
// src/monty.gen.ts follows the registry: when the stored manifest
|
|
1720
|
+
// moved (another editor, the schema door), regenerate it in place —
|
|
1721
|
+
// the file is generated, never hand-edited, so the overwrite is
|
|
1722
|
+
// unconditional. Demo folders are exempt (the demo rail compiles
|
|
1723
|
+
// their monty.config.ts).
|
|
1739
1724
|
if (
|
|
1740
1725
|
typeof data?.manifestHash === "string" &&
|
|
1741
|
-
existsSync(configPath) &&
|
|
1742
1726
|
data.manifestHash !== readSchemaState(appDir)?.hash &&
|
|
1743
|
-
data.manifestHash !== syncAttemptedHash
|
|
1727
|
+
data.manifestHash !== syncAttemptedHash &&
|
|
1728
|
+
!existsSync(join(appDir, "demo.json"))
|
|
1744
1729
|
) {
|
|
1745
1730
|
syncAttemptedHash = data.manifestHash;
|
|
1746
1731
|
try {
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
if (
|
|
1758
|
-
|
|
1759
|
-
appDir,
|
|
1760
|
-
host,
|
|
1761
|
-
key: loadConfig()?.key ?? cfg.key,
|
|
1762
|
-
slug: meta.slug,
|
|
1763
|
-
force: false,
|
|
1764
|
-
compileAppConfig,
|
|
1765
|
-
fail: (code, fix) => {
|
|
1766
|
-
throw new Error(`${code} — ${fix}`);
|
|
1767
|
-
},
|
|
1768
|
-
});
|
|
1732
|
+
const r = await schemaPull({
|
|
1733
|
+
appDir,
|
|
1734
|
+
host,
|
|
1735
|
+
key: loadConfig()?.key ?? cfg.key,
|
|
1736
|
+
slug: meta.slug,
|
|
1737
|
+
quiet: true,
|
|
1738
|
+
fail: (code, fix) => {
|
|
1739
|
+
throw new Error(`${code} — ${fix}`);
|
|
1740
|
+
},
|
|
1741
|
+
});
|
|
1742
|
+
if (r.wrote) {
|
|
1743
|
+
console.log(`schema: src/monty.gen.ts regenerated from the workspace (${data.manifestHash.slice(0, 12)})`);
|
|
1769
1744
|
}
|
|
1770
1745
|
} catch (e) {
|
|
1771
|
-
console.log(`schema: the workspace
|
|
1746
|
+
console.log(`schema: the workspace config changed but src/monty.gen.ts was NOT regenerated (${String(e?.message ?? e).slice(0, 240)}) — \`monty schema pull\` retries it`);
|
|
1772
1747
|
}
|
|
1773
1748
|
}
|
|
1774
1749
|
return true;
|
|
@@ -1850,26 +1825,8 @@ async function dev() {
|
|
|
1850
1825
|
|
|
1851
1826
|
if (configOnly) {
|
|
1852
1827
|
sf.write({ state: "ready" });
|
|
1853
|
-
console.log(
|
|
1854
|
-
registryOwned
|
|
1855
|
-
? "ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`"
|
|
1856
|
-
: "ready: config-only — saves land LIVE; the workspace renders them within seconds",
|
|
1857
|
-
);
|
|
1828
|
+
console.log("ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`");
|
|
1858
1829
|
void startDevSession();
|
|
1859
|
-
if (!registryOwned) {
|
|
1860
|
-
// Manifest-less apps only: config edits should land in seconds, not a
|
|
1861
|
-
// heartbeat — watch the mtime and trigger an early beat (which
|
|
1862
|
-
// recompiles + pushes). Registry-owned apps have nothing to push.
|
|
1863
|
-
const cfgWatch = setInterval(() => {
|
|
1864
|
-
try {
|
|
1865
|
-
if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
|
|
1866
|
-
} catch { /* transient fs hiccup */ }
|
|
1867
|
-
}, 2000);
|
|
1868
|
-
const stopWatch = () => clearInterval(cfgWatch);
|
|
1869
|
-
process.on("SIGINT", stopWatch);
|
|
1870
|
-
process.on("SIGTERM", stopWatch);
|
|
1871
|
-
process.on("SIGHUP", stopWatch);
|
|
1872
|
-
}
|
|
1873
1830
|
}
|
|
1874
1831
|
|
|
1875
1832
|
let announced = false;
|
|
@@ -2124,7 +2081,7 @@ function startTunnel(port, onUrlChange, onOutput) {
|
|
|
2124
2081
|
});
|
|
2125
2082
|
}
|
|
2126
2083
|
|
|
2127
|
-
// ── monty
|
|
2084
|
+
// ── monty components / page ────────────────────────────────────────────────
|
|
2128
2085
|
// Wraps the shadcn CLI behind the curated catalog: agents ask for a
|
|
2129
2086
|
// capability by plain name ("kanban") and get the blessed, theme-compatible
|
|
2130
2087
|
// implementation. Only catalog registries and core shadcn resolve.
|
|
@@ -2132,7 +2089,7 @@ function startTunnel(port, onUrlChange, onOutput) {
|
|
|
2132
2089
|
function requireAppDir(cmd) {
|
|
2133
2090
|
const appDir = findAppRoot(process.cwd());
|
|
2134
2091
|
if (!appDir) {
|
|
2135
|
-
fail("NOT_A_MONTY_APP", `Not inside a Monty app.
|
|
2092
|
+
fail("NOT_A_MONTY_APP", `Not inside a Monty app. cd into the app's folder (\`monty connect <slug>\` pulls a copy into any folder), then run \`monty ${cmd}\`.`);
|
|
2136
2093
|
}
|
|
2137
2094
|
return appDir;
|
|
2138
2095
|
}
|
|
@@ -2150,29 +2107,31 @@ function resolveComponent(name) {
|
|
|
2150
2107
|
return name;
|
|
2151
2108
|
}
|
|
2152
2109
|
|
|
2153
|
-
// ── monty add
|
|
2110
|
+
// ── monty page add <name> ──────────────────────────────────────────────────
|
|
2154
2111
|
// Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
|
|
2155
2112
|
// use (config-only apps gain src/ + vite from the template — their
|
|
2156
|
-
//
|
|
2113
|
+
// AGENTS.md stays untouched), declares
|
|
2157
2114
|
// `pages.<name> = { kind: "custom", path: "/<name>" }`, and writes the page
|
|
2158
2115
|
// route. DECLARE-FIRST: on a workspace-owned app the entry lands through
|
|
2159
2116
|
// the schema door BEFORE the code exists — a save carrying an undeclared
|
|
2160
2117
|
// route refuses (DEPLOY_UNDECLARED_PAGE). The Shopify model: system pages
|
|
2161
2118
|
// stay shell-rendered; only this page is the app's own code.
|
|
2162
2119
|
|
|
2163
|
-
// Declare the page through the schema door
|
|
2164
|
-
//
|
|
2165
|
-
// still that app's editor, the caller registers the entry there).
|
|
2120
|
+
// Declare the page through the schema door (declare-first is mandatory: a
|
|
2121
|
+
// save carrying an undeclared route refuses). Returns "declared" | "already".
|
|
2166
2122
|
async function declarePageThroughDoor(appDir, pageName) {
|
|
2167
2123
|
const slug = readSlug(appDir);
|
|
2168
2124
|
const { host, key } = loadConfig();
|
|
2169
|
-
if (!slug
|
|
2125
|
+
if (!slug) fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` first.");
|
|
2126
|
+
if (!key) fail("NOT_LOGGED_IN", "Declaring a page lands in the workspace config. Run `monty login` first.");
|
|
2170
2127
|
const read = await fetch(`${host}/api/schema?slug=${slug}`, {
|
|
2171
2128
|
headers: { authorization: `Bearer ${key}` },
|
|
2172
2129
|
}).catch(() => null);
|
|
2173
2130
|
const readBody = await read?.json().catch(() => null);
|
|
2174
|
-
if (!read?.ok || !readBody?.ok
|
|
2175
|
-
|
|
2131
|
+
if (!read?.ok || !readBody?.ok) {
|
|
2132
|
+
fail(readBody?.code ?? "HOST_UNREACHABLE", readBody?.fix ?? `Could not read the workspace config from ${host} — check the connection and retry.`);
|
|
2133
|
+
}
|
|
2134
|
+
const manifest = readBody.manifest ?? { slug, tables: {} };
|
|
2176
2135
|
const keyOf = (n) => n.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2177
2136
|
const declared = Object.keys(manifest.pages ?? {}).find((n) => keyOf(n) === keyOf(pageName));
|
|
2178
2137
|
if (declared) {
|
|
@@ -2198,36 +2157,35 @@ async function declarePageThroughDoor(appDir, pageName) {
|
|
|
2198
2157
|
if (!res.ok || !body?.ok) {
|
|
2199
2158
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Declaring the page through the schema door failed — check the connection and retry.");
|
|
2200
2159
|
}
|
|
2201
|
-
|
|
2160
|
+
// Stamp + regenerate together (the stamp claims the mirror matches).
|
|
2161
|
+
try {
|
|
2162
|
+
const stamp = readAppJson(appDir);
|
|
2163
|
+
writeGenModule(appDir, { ...manifest, pages }, { name: stamp?.name, icon: stamp?.icon });
|
|
2164
|
+
writeSchemaState(appDir, body.hash);
|
|
2165
|
+
} catch { /* state is advisory */ }
|
|
2202
2166
|
return "declared";
|
|
2203
2167
|
}
|
|
2204
2168
|
|
|
2205
2169
|
async function addPage(appDir, pageName) {
|
|
2206
2170
|
if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
|
|
2207
|
-
fail("INVALID_PAGE", 'Usage: monty add
|
|
2171
|
+
fail("INVALID_PAGE", 'Usage: monty page add <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
|
|
2208
2172
|
}
|
|
2209
|
-
const
|
|
2210
|
-
if (!
|
|
2211
|
-
fail("
|
|
2173
|
+
const stamp = readAppJson(appDir);
|
|
2174
|
+
if (typeof stamp?.slug !== "string" || !stamp.slug) {
|
|
2175
|
+
fail("NOT_CONNECTED", `No app identity in ${appDir} (.monty/app.json) — run this inside a Monty app.`);
|
|
2212
2176
|
}
|
|
2213
|
-
const
|
|
2214
|
-
const appName = config.match(/name: "([^"]*)"/)?.[1] ?? pageName;
|
|
2177
|
+
const appName = stamp.name ?? pageName;
|
|
2215
2178
|
const routeFile = join(appDir, "src", "routes", `${pageName}.tsx`);
|
|
2216
2179
|
if (existsSync(routeFile)) {
|
|
2217
2180
|
fail("PAGE_EXISTS", `src/routes/${pageName}.tsx already exists. Edit it, or pick a different page name.`);
|
|
2218
2181
|
}
|
|
2219
|
-
if (config.includes(`"/${pageName}"`) || new RegExp(`^\\s*${pageName}:`, "m").test(config)) {
|
|
2220
|
-
console.log(`note: "${pageName}" may already be declared in monty.config.ts — check its pages block after this.`);
|
|
2221
|
-
}
|
|
2222
2182
|
// Declare BEFORE any code exists: if the door refuses, nothing to clean up.
|
|
2223
2183
|
const declared = await declarePageThroughDoor(appDir, pageName);
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
declared
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
);
|
|
2230
|
-
}
|
|
2184
|
+
console.log(
|
|
2185
|
+
declared === "declared"
|
|
2186
|
+
? `declared: pages.${pageName} through the schema door — live in the workspace now`
|
|
2187
|
+
: `declared: pages.${pageName} already in the workspace manifest`,
|
|
2188
|
+
);
|
|
2231
2189
|
|
|
2232
2190
|
// First custom page on a config-only app: bring in the SPA scaffold.
|
|
2233
2191
|
if (isConfigOnlyApp(appDir)) {
|
|
@@ -2235,7 +2193,7 @@ async function addPage(appDir, pageName) {
|
|
|
2235
2193
|
const templateDir = [
|
|
2236
2194
|
join(pkgRoot, "template"),
|
|
2237
2195
|
join(pkgRoot, "..", "template"),
|
|
2238
|
-
].find((d) => existsSync(join(d, "
|
|
2196
|
+
].find((d) => existsSync(join(d, "src", "main.tsx")));
|
|
2239
2197
|
if (!templateDir) {
|
|
2240
2198
|
fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
|
|
2241
2199
|
}
|
|
@@ -2247,7 +2205,7 @@ async function addPage(appDir, pageName) {
|
|
|
2247
2205
|
const base = basename(src);
|
|
2248
2206
|
if (["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base)) return false;
|
|
2249
2207
|
// The app keeps its own identity files.
|
|
2250
|
-
if (["
|
|
2208
|
+
if (["AGENTS.md", "CLAUDE.md"].includes(base)) return false;
|
|
2251
2209
|
return true;
|
|
2252
2210
|
},
|
|
2253
2211
|
});
|
|
@@ -2289,9 +2247,19 @@ async function addPage(appDir, pageName) {
|
|
|
2289
2247
|
// the only route beside __root.
|
|
2290
2248
|
const starter = join(appDir, "src", "routes", "index.tsx");
|
|
2291
2249
|
if (existsSync(starter)) rmSync(starter);
|
|
2250
|
+
// The template's placeholder src/monty.gen.ts carries no real schema —
|
|
2251
|
+
// regenerate the mirror from the workspace so the new page types
|
|
2252
|
+
// against the actual tables.
|
|
2253
|
+
try {
|
|
2254
|
+
const { host, key } = loadConfig();
|
|
2255
|
+
await schemaPull({ appDir, host, key, slug: readSlug(appDir), quiet: true, fail: (c, f) => { throw new Error(`${c} — ${f}`); } });
|
|
2256
|
+
console.log("schema: src/monty.gen.ts generated from the workspace config");
|
|
2257
|
+
} catch (e) {
|
|
2258
|
+
console.log(`schema: could not generate src/monty.gen.ts (${String(e?.message ?? e).slice(0, 160)}) — \`monty schema pull\` retries it`);
|
|
2259
|
+
}
|
|
2292
2260
|
appendFileSync(
|
|
2293
2261
|
join(appDir, "AGENTS.md"),
|
|
2294
|
-
`\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
|
|
2262
|
+
`\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 page add <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`,
|
|
2295
2263
|
);
|
|
2296
2264
|
}
|
|
2297
2265
|
|
|
@@ -2340,30 +2308,6 @@ function ${pageComponentName(pageName)}() {
|
|
|
2340
2308
|
}
|
|
2341
2309
|
`);
|
|
2342
2310
|
console.log(`page: src/routes/${pageName}.tsx`);
|
|
2343
|
-
|
|
2344
|
-
// Manifest-less apps only: the config file is still their editor, so the
|
|
2345
|
-
// entry registers there (workspace-owned apps declared through the door
|
|
2346
|
-
// above — a config edit would be inert).
|
|
2347
|
-
if (declared === "config") {
|
|
2348
|
-
const entry = ` ${JSON.stringify(pageName).includes("-") ? JSON.stringify(pageName) : pageName}: { kind: "custom", path: "/${pageName}" },`;
|
|
2349
|
-
let next = null;
|
|
2350
|
-
if (/^(\s*)pages:\s*{/m.test(config)) {
|
|
2351
|
-
next = config.replace(/^(\s*)pages:\s*{/m, (m) => `${m}\n${entry}`);
|
|
2352
|
-
} else {
|
|
2353
|
-
// No pages block: add one right before the config's closing `});`.
|
|
2354
|
-
const close = config.lastIndexOf("});");
|
|
2355
|
-
if (close !== -1) {
|
|
2356
|
-
next = config.slice(0, close) + ` pages: {\n${entry}\n },\n` + config.slice(close);
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
if (next) {
|
|
2360
|
-
writeFileSync(configPath, next);
|
|
2361
|
-
console.log(`config: pages.${pageName} registered in monty.config.ts`);
|
|
2362
|
-
} else {
|
|
2363
|
-
console.log(`config: could not auto-edit monty.config.ts — add this to defineApp yourself:\n pages: { ${pageName}: { kind: "custom", path: "/${pageName}" } }`);
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
|
|
2367
2311
|
console.log(`added: custom page "${pageName}"`);
|
|
2368
2312
|
console.log(`next: monty install && monty dev — the shell mounts /${pageName} live; \`monty save\` pushes it to the cloud copy.`);
|
|
2369
2313
|
}
|
|
@@ -2372,16 +2316,17 @@ function pageComponentName(pageName) {
|
|
|
2372
2316
|
return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
|
|
2373
2317
|
}
|
|
2374
2318
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
if (names[0] === "page") {
|
|
2382
|
-
await addPage(appDir, names[1]);
|
|
2383
|
-
return;
|
|
2319
|
+
// The page namespace: `monty page add <name>` scaffolds a bespoke page
|
|
2320
|
+
// (declare-first through the schema door).
|
|
2321
|
+
async function pageCmd() {
|
|
2322
|
+
const [sub, name] = rest.filter((a) => !a.startsWith("--"));
|
|
2323
|
+
if (sub !== "add") {
|
|
2324
|
+
fail("INVALID_PAGE", 'Usage: monty page add <name> — scaffold a custom page (e.g. `monty page add reports`).');
|
|
2384
2325
|
}
|
|
2326
|
+
await addPage(requireAppDir("page"), name);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
async function addComponents(appDir, names) {
|
|
2385
2330
|
const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
|
|
2386
2331
|
|
|
2387
2332
|
// --overwrite so shadcn never halts on a per-file prompt (an aborted prompt
|
|
@@ -2423,31 +2368,50 @@ async function add() {
|
|
|
2423
2368
|
console.log(`added: ${items.join(", ")} -> src/components (already themed; import and compose)`);
|
|
2424
2369
|
}
|
|
2425
2370
|
|
|
2426
|
-
|
|
2427
|
-
|
|
2371
|
+
// The component namespace: `monty components` lists/searches the curated
|
|
2372
|
+
// catalog, `components add <name...>` installs, `components docs <name>`
|
|
2373
|
+
// prints a component's source. (`search` is the explicit search subcommand;
|
|
2374
|
+
// a bare query searches too.)
|
|
2375
|
+
async function componentsCmd() {
|
|
2376
|
+
const args = rest.filter((a) => !a.startsWith("--"));
|
|
2377
|
+
const [sub, ...tail] = args;
|
|
2378
|
+
if (sub === "add") {
|
|
2379
|
+
if (tail.length === 0) {
|
|
2380
|
+
fail("NO_COMPONENT", "Usage: monty components add <name...> — `monty components` lists the catalog; core shadcn components install by bare name.");
|
|
2381
|
+
}
|
|
2382
|
+
await addComponents(requireAppDir("components"), tail);
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
if (sub === "docs") {
|
|
2386
|
+
if (!tail[0]) {
|
|
2387
|
+
fail("NO_COMPONENT", "Usage: monty components docs <name> — `monty components` lists the catalog.");
|
|
2388
|
+
}
|
|
2389
|
+
componentDocs(requireAppDir("components"), tail[0]);
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
listComponents((sub === "search" ? tail : args).join(" ").toLowerCase());
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function listComponents(query) {
|
|
2428
2396
|
const entries = Object.entries(CATALOG).filter(
|
|
2429
2397
|
([name, { item, use }]) => !query || `${name} ${item} ${use}`.toLowerCase().includes(query),
|
|
2430
2398
|
);
|
|
2431
2399
|
const width = Math.max(...Object.keys(CATALOG).map((n) => n.length));
|
|
2432
2400
|
const itemWidth = Math.max(...Object.values(CATALOG).map((c) => c.item.length));
|
|
2433
|
-
console.log(`components: ${entries.length} curated (install with \`monty add <name>\`)`);
|
|
2401
|
+
console.log(`components: ${entries.length} curated (install with \`monty components add <name>\`)`);
|
|
2434
2402
|
for (const [name, { item, use }] of entries) {
|
|
2435
2403
|
console.log(` ${name.padEnd(width)} ${item.padEnd(itemWidth)} ${use}`);
|
|
2436
2404
|
}
|
|
2437
2405
|
console.log("core: any shadcn component installs by bare name (dialog, tabs, dropdown-menu, popover, tooltip, sheet, checkbox, textarea, ...)");
|
|
2438
|
-
console.log("docs: `monty docs <name>` shows a component's source before installing");
|
|
2406
|
+
console.log("docs: `monty components docs <name>` shows a component's source before installing");
|
|
2439
2407
|
}
|
|
2440
2408
|
|
|
2441
|
-
|
|
2442
|
-
const appDir = requireAppDir("docs");
|
|
2443
|
-
const name = rest.find((a) => !a.startsWith("--"));
|
|
2444
|
-
if (!name) {
|
|
2445
|
-
fail("NO_COMPONENT", "Usage: monty docs <name> — run `monty components` to see what's available.");
|
|
2446
|
-
}
|
|
2409
|
+
function componentDocs(appDir, name) {
|
|
2447
2410
|
run(appDir, "docs", ["pnpm", "dlx", "shadcn@latest", "view", resolveComponent(name)],
|
|
2448
2411
|
`Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
|
|
2449
2412
|
}
|
|
2450
2413
|
|
|
2414
|
+
|
|
2451
2415
|
// ── monty secret ─────────────────────────────────────────────────────────
|
|
2452
2416
|
// `monty secret set KEY [value]` / `monty secret rm KEY` — per-app
|
|
2453
2417
|
// server-function secrets. The value goes to Cloudflare's per-script secrets
|
|
@@ -2455,12 +2419,11 @@ async function docs() {
|
|
|
2455
2419
|
// readable back. Read from the arg, then a TTY prompt, then stdin (piping).
|
|
2456
2420
|
async function secret() {
|
|
2457
2421
|
const appDir = requireAppDir("secret");
|
|
2458
|
-
// Identity comes from the stamp when it exists — no config compile for
|
|
2459
|
-
// one slug read.
|
|
2460
2422
|
const stamped = readAppJson(appDir);
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2423
|
+
if (typeof stamped?.slug !== "string" || !stamped.slug) {
|
|
2424
|
+
fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here first.");
|
|
2425
|
+
}
|
|
2426
|
+
const meta = { slug: stamped.slug };
|
|
2464
2427
|
const config = loadConfig();
|
|
2465
2428
|
if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
2466
2429
|
const [sub, name] = rest.filter((a) => !a.startsWith("-"));
|
|
@@ -2498,10 +2461,11 @@ async function secret() {
|
|
|
2498
2461
|
}
|
|
2499
2462
|
|
|
2500
2463
|
// ── monty save ─────────────────────────────────────────────────────────────
|
|
2501
|
-
// Push the working copy to the cloud copy, like `git push main
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2464
|
+
// Push the working copy to the cloud copy, like `git push main` (build +
|
|
2465
|
+
// typecheck gate every save, then one multipart POST). A save ships
|
|
2466
|
+
// IMPLEMENTATION ONLY — the app's config lives in the workspace and lands
|
|
2467
|
+
// through the doors; the config file is never compiled here. The optional
|
|
2468
|
+
// message rides the meta so the platform can narrate the save later.
|
|
2505
2469
|
async function deploy() {
|
|
2506
2470
|
const appDir = requireAppDir(command);
|
|
2507
2471
|
ensureSdk(appDir);
|
|
@@ -2521,40 +2485,50 @@ async function deploy() {
|
|
|
2521
2485
|
fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
|
|
2522
2486
|
}
|
|
2523
2487
|
|
|
2524
|
-
// 1)
|
|
2525
|
-
//
|
|
2526
|
-
|
|
2527
|
-
const
|
|
2528
|
-
|
|
2488
|
+
// 1) Identity from the stamp. Neither the /__monty/public allowlist nor
|
|
2489
|
+
// clock work rides a save: sharing is door-owned (`monty public set`)
|
|
2490
|
+
// and cron is an `every` rule (the rules door).
|
|
2491
|
+
const stamp = readAppJson(appDir);
|
|
2492
|
+
if (typeof stamp?.slug !== "string" || !stamp.slug) {
|
|
2493
|
+
fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here to join an existing app, or `monty create <slug>` for a new one.");
|
|
2494
|
+
}
|
|
2495
|
+
const meta = {
|
|
2496
|
+
slug: stamp.slug,
|
|
2497
|
+
name: stamp.name ?? stamp.slug,
|
|
2498
|
+
icon: stamp.icon,
|
|
2499
|
+
};
|
|
2529
2500
|
// `monty save "what changed"` — the message rides the meta for the
|
|
2530
2501
|
// platform to render as this save's Activity row.
|
|
2531
2502
|
if (message) meta.message = message;
|
|
2503
|
+
await migrateConfigToGen(appDir, { host: config.host, key: config.key, slug: meta.slug });
|
|
2532
2504
|
|
|
2533
|
-
//
|
|
2534
|
-
//
|
|
2535
|
-
// sending them keeps the wire honest). Code-door declarations
|
|
2536
|
-
// (publicFns, schedule, pages, functions) still ride.
|
|
2537
|
-
const registryOwned = readAppJson(appDir)?.registryOwned === true;
|
|
2538
|
-
if (registryOwned) {
|
|
2539
|
-
delete meta.schemaJson;
|
|
2540
|
-
delete meta.manifest;
|
|
2541
|
-
delete meta.exposure;
|
|
2542
|
-
delete meta.rules;
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
// CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
|
|
2546
|
-
// the platform shell renders the app.
|
|
2505
|
+
// CONFIG-ONLY apps have no bundle — the platform shell renders them; the
|
|
2506
|
+
// save carries the source snapshot only.
|
|
2547
2507
|
const configOnly = isConfigOnlyApp(appDir);
|
|
2548
|
-
if (
|
|
2549
|
-
|
|
2550
|
-
|
|
2508
|
+
if (configOnly) {
|
|
2509
|
+
meta.configOnly = true;
|
|
2510
|
+
} else {
|
|
2511
|
+
// Refresh the generated mirror before the build/typecheck: code written
|
|
2512
|
+
// against a schema another surface just changed must see it.
|
|
2513
|
+
if (!existsSync(join(appDir, "demo.json"))) {
|
|
2514
|
+
try {
|
|
2515
|
+
await schemaPull({ appDir, host: config.host, key: config.key, slug: meta.slug, quiet: true, fail: (c, f) => { throw new Error(`${c} — ${f}`); } });
|
|
2516
|
+
} catch (e) {
|
|
2517
|
+
console.log(`schema: could not refresh src/monty.gen.ts (${String(e?.message ?? e).slice(0, 160)}) — building with the local copy`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
// 2) Fail fast locally before any upload. Style lint first (cheapest,
|
|
2521
|
+
// same contract as the typecheck: off-token styling never ships) —
|
|
2522
|
+
// then build (it also generates src/routeTree.gen.ts, without which
|
|
2523
|
+
// tsc fails on a fresh checkout), then typecheck.
|
|
2524
|
+
const styleViolations = await lintStyles(appDir);
|
|
2525
|
+
if (styleViolations.length > 0) {
|
|
2526
|
+
fail("STYLE_OFF_TOKENS", `Off-token styling below — the platform look is tokens-only, and \`monty save\` never uploads styling off the vocabulary.\n${await formatViolations(styleViolations)}\nThe vocabulary is the monty-design skill; \`monty style\` re-checks. A deliberate exception takes \`// monty-style-ignore\` on its line.`);
|
|
2527
|
+
}
|
|
2551
2528
|
run(appDir, "build", ["npx", "vite", "build"],
|
|
2552
2529
|
"The production build failed. Read the vite error above; it names the file to fix.");
|
|
2553
2530
|
run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
|
|
2554
2531
|
"TypeScript errors above. Fix them in the listed files; `monty save` never uploads code that does not compile.");
|
|
2555
|
-
} else if (!registryOwned && !meta.manifest) {
|
|
2556
|
-
fail("MANIFEST_MISSING",
|
|
2557
|
-
"This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
|
|
2558
2532
|
}
|
|
2559
2533
|
|
|
2560
2534
|
// 3) Multipart POST to the host.
|
|
@@ -2568,41 +2542,15 @@ async function deploy() {
|
|
|
2568
2542
|
// 3a) Server functions (optional): bundle server/index.ts into one worker
|
|
2569
2543
|
// script and ride the SAME deploy. The manifest (fns) goes in meta so
|
|
2570
2544
|
// the router gates /__monty/fn/* without a lookup.
|
|
2571
|
-
const serverBundle = await bundleServerFns(appDir
|
|
2572
|
-
const publicFns = Array.isArray(meta.publicFns) ? meta.publicFns : [];
|
|
2573
|
-
const scheduleEntries = Object.entries(meta.schedule ?? {});
|
|
2574
|
-
if (!serverBundle && (publicFns.length > 0 || scheduleEntries.length > 0)) {
|
|
2575
|
-
fail("SERVER_DIR_MISSING",
|
|
2576
|
-
"monty.config.ts declares publicFns/schedule, but this app has no server/index.ts. Create it with the named exports, or remove the declarations.");
|
|
2577
|
-
}
|
|
2545
|
+
const serverBundle = await bundleServerFns(appDir);
|
|
2578
2546
|
if (serverBundle) {
|
|
2579
|
-
|
|
2580
|
-
if (!serverBundle.fns.includes(name)) {
|
|
2581
|
-
fail("PUBLIC_FN_UNKNOWN",
|
|
2582
|
-
`publicFns names "${name}" but server/index.ts exports no such function. Export it (e.g. \`export async function ${name}(req, ctx) {…}\`) or remove it from monty.config.ts.`);
|
|
2583
|
-
}
|
|
2584
|
-
}
|
|
2585
|
-
for (const [name] of scheduleEntries) {
|
|
2586
|
-
if (!serverBundle.fns.includes(name)) {
|
|
2587
|
-
fail("SCHEDULE_UNKNOWN_FN",
|
|
2588
|
-
`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.`);
|
|
2589
|
-
}
|
|
2590
|
-
}
|
|
2591
|
-
// Wire compat: meta.fns stays the UNION (older hosts gate dispatch on
|
|
2592
|
-
// it); meta.datasets is the additive split newer hosts classify with.
|
|
2593
|
-
meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
|
|
2547
|
+
if (serverBundle.fns.length > 0) meta.fns = serverBundle.fns;
|
|
2594
2548
|
if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
|
|
2595
2549
|
form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
|
|
2596
2550
|
const bundled = [];
|
|
2597
2551
|
if (serverBundle.fns.length > 0) bundled.push(`${serverBundle.fns.length} function(s) (${serverBundle.fns.join(", ")})`);
|
|
2598
2552
|
if (serverBundle.datasets.length > 0) bundled.push(`${serverBundle.datasets.length} dataset(s) (${serverBundle.datasets.join(", ")})`);
|
|
2599
2553
|
console.log(`fns: bundled ${bundled.join(" + ")}`);
|
|
2600
|
-
if (publicFns.length > 0) {
|
|
2601
|
-
console.log(`public: ${publicFns.map((f) => `/__monty/public/${f}`).join(", ")} — open to the internet; verify signatures in the function`);
|
|
2602
|
-
}
|
|
2603
|
-
if (scheduleEntries.length > 0) {
|
|
2604
|
-
console.log(`schedule: ${scheduleEntries.map(([f, c]) => `${f} @ "${c}"`).join(", ")} (UTC; Live cron changes can take ~15 min to propagate)`);
|
|
2605
|
-
}
|
|
2606
2554
|
}
|
|
2607
2555
|
// 3a½) Custom pages, by route-file convention (top-level src/routes/
|
|
2608
2556
|
// files) — registered as fnsJson.pages so the shell's nav knows what this
|
|
@@ -2618,7 +2566,7 @@ async function deploy() {
|
|
|
2618
2566
|
// only the minified bundle and the sole copy of the app's code is this
|
|
2619
2567
|
// folder — delete it and the source is gone forever. The snapshot is what
|
|
2620
2568
|
// `monty pull <slug>` restores on any machine, and the publish lands in
|
|
2621
|
-
// the same version history as `monty
|
|
2569
|
+
// the same version history as `monty save`.
|
|
2622
2570
|
let sourceHash = null;
|
|
2623
2571
|
{
|
|
2624
2572
|
const packed = packSource(appDir);
|
|
@@ -2633,13 +2581,6 @@ async function deploy() {
|
|
|
2633
2581
|
console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
|
|
2634
2582
|
}
|
|
2635
2583
|
}
|
|
2636
|
-
// V2 schema CAS: prove which stored manifest this checkout last synced,
|
|
2637
|
-
// so an API/MCP edit made meanwhile surfaces as MANIFEST_DRIFT instead of
|
|
2638
|
-
// being clobbered (fix: `monty schema pull`).
|
|
2639
|
-
if (meta.manifest !== undefined) {
|
|
2640
|
-
const state = readSchemaState(appDir);
|
|
2641
|
-
if (state?.hash) meta.baseManifestHash = state.hash;
|
|
2642
|
-
}
|
|
2643
2584
|
form.set("monty", JSON.stringify(meta));
|
|
2644
2585
|
let total = 0;
|
|
2645
2586
|
for (const file of files) {
|
|
@@ -2650,7 +2591,7 @@ async function deploy() {
|
|
|
2650
2591
|
}
|
|
2651
2592
|
console.log(
|
|
2652
2593
|
configOnly
|
|
2653
|
-
? `upload: config-only (
|
|
2594
|
+
? `upload: config-only (source snapshot, no bundle) -> ${config.host}/api/deploy`
|
|
2654
2595
|
: `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
|
|
2655
2596
|
);
|
|
2656
2597
|
const res = await fetch(`${config.host}/api/deploy`, {
|
|
@@ -2660,22 +2601,10 @@ async function deploy() {
|
|
|
2660
2601
|
});
|
|
2661
2602
|
const body = await res.json().catch(() => null);
|
|
2662
2603
|
if (!res.ok || !body?.ok) {
|
|
2663
|
-
if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
|
|
2664
|
-
console.log(`schema drift (remote changes):\n${body.summary}`);
|
|
2665
|
-
}
|
|
2666
2604
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
|
|
2667
2605
|
}
|
|
2668
2606
|
console.log(`origin: ${body.origin}`);
|
|
2669
2607
|
console.log(`saved: ${body.url} (version ${body.version})`);
|
|
2670
|
-
if (body.manifestDrift) {
|
|
2671
|
-
// The code shipped; the config was HELD — the stored schema changed
|
|
2672
|
-
// since this checkout last synced (another editor).
|
|
2673
|
-
console.log(`schema drift (remote changes):${body.manifestDrift.summary ? `\n${body.manifestDrift.summary}` : ""}`);
|
|
2674
|
-
console.log(`config push held: ${body.manifestDrift.fix ?? "Run `monty schema pull`, merge, then deploy again."}`);
|
|
2675
|
-
} else if (meta.manifest !== undefined) {
|
|
2676
|
-
// The manifest just published IS the new CAS base.
|
|
2677
|
-
writeSchemaState(appDir, manifestHash(meta.manifest));
|
|
2678
|
-
}
|
|
2679
2608
|
// Stamp what was published — pull uses this to tell "unchanged since last
|
|
2680
2609
|
// sync" from "locally modified".
|
|
2681
2610
|
if (sourceHash) {
|
|
@@ -2694,7 +2623,7 @@ async function deploy() {
|
|
|
2694
2623
|
// fn-worker's makeFnWorker; esbuild bundles it for workerd. node: imports are
|
|
2695
2624
|
// rejected at compile time — Live runs on Cloudflare Workers, not Node.
|
|
2696
2625
|
// Returns { code, fns, datasets } (per-folder export names) or null.
|
|
2697
|
-
async function bundleServerFns(appDir
|
|
2626
|
+
async function bundleServerFns(appDir) {
|
|
2698
2627
|
const serverEntry = join(appDir, "server", "index.ts");
|
|
2699
2628
|
const datasetsEntry = join(appDir, "datasets", "index.ts");
|
|
2700
2629
|
const hasServer = existsSync(serverEntry);
|
|
@@ -2705,14 +2634,11 @@ async function bundleServerFns(appDir, schedule) {
|
|
|
2705
2634
|
mkdirSync(tmpDir, { recursive: true });
|
|
2706
2635
|
const entry = join(tmpDir, "fn-worker-entry.mjs");
|
|
2707
2636
|
const out = join(tmpDir, "fn-worker-out.mjs");
|
|
2708
|
-
// The schedule map is baked into the bundle: Cloudflare's scheduled()
|
|
2709
|
-
// hands back only the matching cron expression, so the worker needs the
|
|
2710
|
-
// expression→fn mapping at runtime.
|
|
2711
2637
|
writeFileSync(entry, [
|
|
2712
2638
|
hasServer ? `import * as appFns from "../server/index";` : `const appFns = {};`,
|
|
2713
2639
|
hasDatasets ? `import * as appDatasets from "../datasets/index";` : `const appDatasets = {};`,
|
|
2714
2640
|
`import { makeFnWorker } from "@montytools/sdk/fn-worker";`,
|
|
2715
|
-
`export default makeFnWorker({ ...appFns, ...appDatasets }
|
|
2641
|
+
`export default makeFnWorker({ ...appFns, ...appDatasets });`,
|
|
2716
2642
|
].join("\n"));
|
|
2717
2643
|
// Fail the deploy if server code reaches for Node built-ins — a Worker
|
|
2718
2644
|
// can't run them, and a silent runtime crash on Live is the worst outcome.
|
|
@@ -2777,25 +2703,6 @@ function discoverFnExports(serverEntry) {
|
|
|
2777
2703
|
return [...names];
|
|
2778
2704
|
}
|
|
2779
2705
|
|
|
2780
|
-
// Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
|
|
2781
|
-
// the session heartbeat's last good schema through transient config breakage.
|
|
2782
|
-
async function compileConfig(appDir, { soft = false } = {}) {
|
|
2783
|
-
try {
|
|
2784
|
-
// Config-only apps (no SPA) always compile a manifest: the platform
|
|
2785
|
-
// shell is their only renderer.
|
|
2786
|
-
return await compileAppConfig(appDir, { forceManifest: isConfigOnlyApp(appDir) });
|
|
2787
|
-
} catch (e) {
|
|
2788
|
-
if (e instanceof CompileError) {
|
|
2789
|
-
if (soft) {
|
|
2790
|
-
console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
|
|
2791
|
-
return null;
|
|
2792
|
-
}
|
|
2793
|
-
fail(e.code, e.fix);
|
|
2794
|
-
}
|
|
2795
|
-
throw e;
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
2706
|
function run(cwd, label, argv, fixOnFail) {
|
|
2800
2707
|
console.log(`${label}: ${argv.join(" ")}`);
|
|
2801
2708
|
// stdin ignored: interactive prompts (e.g. shadcn's per-file overwrite
|
|
@@ -2821,58 +2728,6 @@ function walk(dir) {
|
|
|
2821
2728
|
// UTC, 5 fields, standard syntax: * a,b a-b */n a-b/n plus month/day
|
|
2822
2729
|
// names (JAN, MON). Deliberately forgiving: an unparsable field simply never
|
|
2823
2730
|
// matches locally — Cloudflare is the syntax authority at deploy, so a bad
|
|
2824
|
-
// expression fails there with its own message.
|
|
2825
|
-
const CRON_MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
|
|
2826
|
-
const CRON_DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
2827
|
-
|
|
2828
|
-
function cronMatches(expr, date) {
|
|
2829
|
-
const fields = String(expr).trim().split(/\s+/);
|
|
2830
|
-
if (fields.length !== 5) return false;
|
|
2831
|
-
const values = [
|
|
2832
|
-
date.getUTCMinutes(),
|
|
2833
|
-
date.getUTCHours(),
|
|
2834
|
-
date.getUTCDate(),
|
|
2835
|
-
date.getUTCMonth() + 1,
|
|
2836
|
-
date.getUTCDay(),
|
|
2837
|
-
];
|
|
2838
|
-
const bounds = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
2839
|
-
return fields.every((field, i) => cronFieldMatches(field, values[i], bounds[i], i));
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
function cronFieldMatches(field, value, [lo, hi], idx) {
|
|
2843
|
-
const names = idx === 3 ? CRON_MONTHS : idx === 4 ? CRON_DAYS : null;
|
|
2844
|
-
const num = (t) => {
|
|
2845
|
-
const named = names?.[t.toLowerCase()];
|
|
2846
|
-
if (named !== undefined) return named;
|
|
2847
|
-
const n = Number(t);
|
|
2848
|
-
return Number.isInteger(n) ? n : null;
|
|
2849
|
-
};
|
|
2850
|
-
for (const part of field.split(",")) {
|
|
2851
|
-
const [rangeRaw, stepRaw] = part.split("/");
|
|
2852
|
-
const step = stepRaw === undefined ? 1 : Number(stepRaw);
|
|
2853
|
-
if (!Number.isInteger(step) || step < 1) continue;
|
|
2854
|
-
let from;
|
|
2855
|
-
let to;
|
|
2856
|
-
if (rangeRaw === "*" || rangeRaw === "") {
|
|
2857
|
-
from = lo;
|
|
2858
|
-
to = hi;
|
|
2859
|
-
} else if (rangeRaw.includes("-")) {
|
|
2860
|
-
const [a, b] = rangeRaw.split("-");
|
|
2861
|
-
from = num(a);
|
|
2862
|
-
to = num(b);
|
|
2863
|
-
} else {
|
|
2864
|
-
from = num(rangeRaw);
|
|
2865
|
-
to = stepRaw === undefined ? from : hi; // "5/10": from 5 to max, step 10
|
|
2866
|
-
}
|
|
2867
|
-
if (from === null || to === null || from > to) continue;
|
|
2868
|
-
for (let v = from; v <= to; v += step) {
|
|
2869
|
-
// day-of-week: cron accepts 7 for Sunday alongside 0
|
|
2870
|
-
if (v === value || (idx === 4 && v === 7 && value === 0)) return true;
|
|
2871
|
-
}
|
|
2872
|
-
}
|
|
2873
|
-
return false;
|
|
2874
|
-
}
|
|
2875
|
-
|
|
2876
2731
|
// ── monty data ─────────────────────────────────────────────────────────────
|
|
2877
2732
|
// The agent verbs for OPERATING an app: read and write its records from any
|
|
2878
2733
|
// terminal — no browser, no dev session. Auth is the mk_ key exchanged at
|
|
@@ -2917,10 +2772,7 @@ function resolveDataApp() {
|
|
|
2917
2772
|
const root = findAppRoot(process.cwd());
|
|
2918
2773
|
const slug = explicit ?? (root ? readSlugFromConfig(root) : null);
|
|
2919
2774
|
if (!slug) {
|
|
2920
|
-
fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder.
|
|
2921
|
-
}
|
|
2922
|
-
if (rest.includes("--studio")) {
|
|
2923
|
-
fail("STUDIO_REMOVED", "The session sandbox is gone — every app has one set of records, and data verbs always target it. Drop --studio.");
|
|
2775
|
+
fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder.");
|
|
2924
2776
|
}
|
|
2925
2777
|
return slug;
|
|
2926
2778
|
}
|
|
@@ -2994,7 +2846,7 @@ function flattenRow(doc) {
|
|
|
2994
2846
|
|
|
2995
2847
|
function dataUsage() {
|
|
2996
2848
|
console.log("usage: monty data <verb> [table] [flags] read/write an app's records");
|
|
2997
|
-
console.log(" schema [table] the app's table shapes (from
|
|
2849
|
+
console.log(" schema [table] the app's table shapes (read from the workspace)");
|
|
2998
2850
|
console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
|
|
2999
2851
|
console.log(" get <table> <id>");
|
|
3000
2852
|
console.log(" insert <table> --data '<json|[json,…]>'");
|
|
@@ -3056,33 +2908,17 @@ async function data() {
|
|
|
3056
2908
|
const [verb, table, id] = dataPositionals();
|
|
3057
2909
|
|
|
3058
2910
|
if (verb === "schema") {
|
|
3059
|
-
// Shape comes from the
|
|
3060
|
-
//
|
|
3061
|
-
const
|
|
3062
|
-
const
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
}
|
|
3070
|
-
let meta;
|
|
3071
|
-
try {
|
|
3072
|
-
meta = await compileAppConfig(root);
|
|
3073
|
-
} catch (e) {
|
|
3074
|
-
if (e instanceof CompileError) fail(e.code, e.fix);
|
|
3075
|
-
throw e;
|
|
3076
|
-
}
|
|
3077
|
-
const tables = meta.schemaJson?.tables ?? {};
|
|
3078
|
-
if (table !== undefined) {
|
|
3079
|
-
if (!tables[table]) {
|
|
3080
|
-
fail("NO_SUCH_TABLE", `App "${meta.slug}" has no table "${table}". Tables: ${Object.keys(tables).join(", ") || "(none)"}.`);
|
|
3081
|
-
}
|
|
3082
|
-
printJson({ app: meta.slug, table, schema: tables[table] });
|
|
3083
|
-
} else {
|
|
3084
|
-
printJson({ app: meta.slug, tables });
|
|
3085
|
-
}
|
|
2911
|
+
// Shape comes from the WORKSPACE — the stored schema the platform
|
|
2912
|
+
// validates against (derived from the app's config).
|
|
2913
|
+
const app = resolveDataApp();
|
|
2914
|
+
const auth = await workspaceAuth();
|
|
2915
|
+
const result = await callRecords(
|
|
2916
|
+
"query",
|
|
2917
|
+
"schema",
|
|
2918
|
+
table !== undefined ? { app, table } : { app },
|
|
2919
|
+
auth,
|
|
2920
|
+
);
|
|
2921
|
+
printJson({ app, ...result });
|
|
3086
2922
|
return;
|
|
3087
2923
|
}
|
|
3088
2924
|
|
|
@@ -3267,7 +3103,7 @@ async function data() {
|
|
|
3267
3103
|
// The CLI accepts concise filters, then stores the shell's existing
|
|
3268
3104
|
// { filters, sort, hidden } ViewConfig shape unchanged.
|
|
3269
3105
|
|
|
3270
|
-
const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name"]);
|
|
3106
|
+
const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name", "kanban"]);
|
|
3271
3107
|
|
|
3272
3108
|
function viewPositionals() {
|
|
3273
3109
|
const out = [];
|
|
@@ -3285,10 +3121,11 @@ function viewPositionals() {
|
|
|
3285
3121
|
function viewsUsage() {
|
|
3286
3122
|
console.log("usage: monty views <list|set|update|remove> <table> [name] [flags]");
|
|
3287
3123
|
console.log(" list <table> [--app slug]");
|
|
3288
|
-
console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--app slug]");
|
|
3289
|
-
console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--app slug]");
|
|
3124
|
+
console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--kanban field|none] [--app slug]");
|
|
3125
|
+
console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--kanban field|none] [--app slug]");
|
|
3290
3126
|
console.log(" remove <table> <name> [--app slug]");
|
|
3291
3127
|
console.log("filter values: null = empty; scalar = exact; array = any listed value; {\"contains\":\"text\"}; {\"min\":0,\"max\":100}");
|
|
3128
|
+
console.log("kanban: the view renders as lanes over the select field's values; \"none\" makes it a table");
|
|
3292
3129
|
console.log("example: monty views set leads \"Evaluate\" --filter '{\"pipelineId\":null}'");
|
|
3293
3130
|
process.exit(1);
|
|
3294
3131
|
}
|
|
@@ -3313,10 +3150,9 @@ function viewName(input) {
|
|
|
3313
3150
|
async function viewContext(table) {
|
|
3314
3151
|
const app = resolveDataApp();
|
|
3315
3152
|
const auth = await workspaceAuth();
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
}
|
|
3153
|
+
// Nothing stored yet is just an empty config — the table lookup below
|
|
3154
|
+
// says what's actually missing.
|
|
3155
|
+
const manifest = (await callConvex("query", "platform:appManifest", { slug: app }, auth)) ?? { tables: {} };
|
|
3320
3156
|
const tableSpec = manifest.tables?.[table];
|
|
3321
3157
|
if (!tableSpec) {
|
|
3322
3158
|
fail("NO_SUCH_TABLE", `App "${app}" has no table "${table}". Tables: ${Object.keys(manifest.tables ?? {}).join(", ") || "(none)"}.`);
|
|
@@ -3343,6 +3179,8 @@ function requestedViewConfig(fields) {
|
|
|
3343
3179
|
sort: parseViewSort(flag("sort")),
|
|
3344
3180
|
hidden: parseHiddenColumns(flag("hide")),
|
|
3345
3181
|
};
|
|
3182
|
+
const kanban = parseKanbanFlag(flag("kanban"));
|
|
3183
|
+
if (kanban) Object.assign(config, kanban);
|
|
3346
3184
|
return validateViewColumns(config, fields);
|
|
3347
3185
|
} catch (error) {
|
|
3348
3186
|
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
@@ -3355,6 +3193,7 @@ function requestedViewPatch() {
|
|
|
3355
3193
|
...(rest.includes("--filter") ? { filters: normalizeViewFilters(parseJsonFlag("filter")) } : {}),
|
|
3356
3194
|
...(rest.includes("--sort") ? { sort: parseViewSort(flag("sort")) } : {}),
|
|
3357
3195
|
...(rest.includes("--hide") ? { hidden: parseHiddenColumns(flag("hide")) } : {}),
|
|
3196
|
+
...(rest.includes("--kanban") ? { kanban: parseKanbanFlag(flag("kanban")) } : {}),
|
|
3358
3197
|
};
|
|
3359
3198
|
} catch (error) {
|
|
3360
3199
|
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
@@ -3405,7 +3244,7 @@ async function views() {
|
|
|
3405
3244
|
}
|
|
3406
3245
|
const patch = requestedViewPatch();
|
|
3407
3246
|
if (nextName === name && Object.keys(patch).length === 0) {
|
|
3408
|
-
fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, or --
|
|
3247
|
+
fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, --hide, or --kanban. Omitted properties stay unchanged.");
|
|
3409
3248
|
}
|
|
3410
3249
|
const config = checkedViewConfig(mergeViewConfig(existing.config, patch), fields);
|
|
3411
3250
|
await callConvex("mutation", "platform:saveView", { app, page: table, name: nextName, config }, auth);
|
|
@@ -3635,9 +3474,9 @@ if (command !== "dev" && command !== "logs" && command !== "support") {
|
|
|
3635
3474
|
// The app's data half (tables, field algebra, metrics, settings, pages)
|
|
3636
3475
|
// lives ONLY in the workspace. `monty schema [slug]` prints the stored
|
|
3637
3476
|
// manifest as JSON (and stamps the CAS base); edit that JSON and
|
|
3638
|
-
// `monty schema set <
|
|
3477
|
+
// `monty schema set '<json>'` (or `set -` piped) writes it back|->` writes it back through the one landing —
|
|
3639
3478
|
// validated server-side, additive-only by default, CAS against what you
|
|
3640
|
-
// read. `monty schema pull`
|
|
3479
|
+
// read. `monty schema pull` regenerates the src/monty.gen.ts mirror.
|
|
3641
3480
|
async function schemaCmd() {
|
|
3642
3481
|
const verb = rest[0];
|
|
3643
3482
|
const { host, key } = loadConfig() ?? {};
|
|
@@ -3646,42 +3485,38 @@ async function schemaCmd() {
|
|
|
3646
3485
|
|
|
3647
3486
|
if (verb === "pull") {
|
|
3648
3487
|
const dir = appDir ?? process.cwd();
|
|
3649
|
-
|
|
3488
|
+
const slug = rest.slice(1).find((a) => !a.startsWith("--")) ?? readSlug(dir);
|
|
3650
3489
|
if (!slug) {
|
|
3651
|
-
|
|
3652
|
-
slug = (await compileAppConfig(dir)).slug;
|
|
3653
|
-
} catch {
|
|
3654
|
-
fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — no compilable monty.config.ts here to read it from.");
|
|
3655
|
-
}
|
|
3490
|
+
fail("INVALID_SLUG", "Pass the app slug (monty schema pull <slug>) — or run it inside an app folder (.monty/app.json carries the identity).");
|
|
3656
3491
|
}
|
|
3657
|
-
await
|
|
3658
|
-
|
|
3659
|
-
force: rest.includes("--force"),
|
|
3660
|
-
compileAppConfig,
|
|
3661
|
-
fail,
|
|
3662
|
-
});
|
|
3492
|
+
const migrated = await migrateConfigToGen(dir, { host, key, slug });
|
|
3493
|
+
if (!migrated) await schemaPull({ appDir: dir, host, key, slug, fail });
|
|
3663
3494
|
return;
|
|
3664
3495
|
}
|
|
3665
3496
|
|
|
3666
3497
|
if (verb === "set") {
|
|
3498
|
+
// The config is passed as JSON, never a file — nothing to leave behind
|
|
3499
|
+
// in the app folder. `-` reads stdin for configs too big for an arg.
|
|
3667
3500
|
const target = rest[1];
|
|
3668
3501
|
if (!target) {
|
|
3669
|
-
fail("SCHEMA_USAGE", "Usage: monty schema set <
|
|
3502
|
+
fail("SCHEMA_USAGE", "Usage: monty schema set '<json>' [--allow-breaking] — the app's full config as JSON (start from `monty schema`; pipe with `monty schema set -`).");
|
|
3670
3503
|
}
|
|
3671
|
-
let raw;
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3504
|
+
let raw = target;
|
|
3505
|
+
if (target === "-") {
|
|
3506
|
+
try {
|
|
3507
|
+
raw = readFileSync(0, "utf8");
|
|
3508
|
+
} catch {
|
|
3509
|
+
fail("SCHEMA_USAGE", "Could not read stdin. Pipe the config JSON in: `monty schema | <edit> | monty schema set -`.");
|
|
3510
|
+
}
|
|
3676
3511
|
}
|
|
3677
3512
|
let manifest;
|
|
3678
3513
|
try {
|
|
3679
3514
|
manifest = JSON.parse(raw);
|
|
3680
3515
|
} catch {
|
|
3681
|
-
fail("
|
|
3516
|
+
fail("BAD_CONFIG_JSON", "That is not valid JSON. Pass the whole config as one JSON argument (start from `monty schema` output), or pipe it with `monty schema set -`.");
|
|
3682
3517
|
}
|
|
3683
3518
|
const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
|
|
3684
|
-
if (!slug) fail("INVALID_SLUG", "The
|
|
3519
|
+
if (!slug) fail("INVALID_SLUG", "The config carries no slug and this is not an app folder — set `slug` in the JSON.");
|
|
3685
3520
|
// CAS: prove which stored manifest this edit was based on (stamped by
|
|
3686
3521
|
// the last `monty schema` read in this folder). Absent = trusting push.
|
|
3687
3522
|
const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
|
|
@@ -3700,15 +3535,22 @@ async function schemaCmd() {
|
|
|
3700
3535
|
if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
|
|
3701
3536
|
console.log(`schema drift (remote changes):\n${body.summary}`);
|
|
3702
3537
|
}
|
|
3703
|
-
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the
|
|
3538
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the config failed — is the Monty host reachable?");
|
|
3539
|
+
}
|
|
3540
|
+
// Stamp + regenerate together (the stamp claims the mirror matches) —
|
|
3541
|
+
// the just-pushed document IS the new config, so the mirror updates
|
|
3542
|
+
// instantly instead of waiting a heartbeat.
|
|
3543
|
+
if (appDir && readSlug(appDir) === slug) {
|
|
3544
|
+
const stamp = readAppJson(appDir);
|
|
3545
|
+
writeGenModule(appDir, manifest, { name: stamp?.name, icon: stamp?.icon });
|
|
3546
|
+
writeSchemaState(appDir, body.hash);
|
|
3704
3547
|
}
|
|
3705
|
-
if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
|
|
3706
3548
|
console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
|
|
3707
3549
|
return;
|
|
3708
3550
|
}
|
|
3709
3551
|
|
|
3710
3552
|
// Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
|
|
3711
|
-
// JSON (
|
|
3553
|
+
// JSON (edit it, then `monty schema set` the whole document back).
|
|
3712
3554
|
const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
|
|
3713
3555
|
if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
|
|
3714
3556
|
const res = await fetch(`${host}/api/schema?slug=${slug}`, {
|
|
@@ -3716,16 +3558,95 @@ async function schemaCmd() {
|
|
|
3716
3558
|
});
|
|
3717
3559
|
const body = await res.json().catch(() => null);
|
|
3718
3560
|
if (!res.ok || !body?.ok) {
|
|
3719
|
-
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the
|
|
3561
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the config — check the connection and `monty login`.");
|
|
3720
3562
|
}
|
|
3721
3563
|
if (body.manifest === null) {
|
|
3722
|
-
|
|
3564
|
+
// Nothing stored yet is just an empty config — hand it back so the
|
|
3565
|
+
// agent can fill it in and `monty schema set` it.
|
|
3566
|
+
console.error(`# ${slug} — empty config (nothing stored yet; "monty schema set" declares it)`);
|
|
3567
|
+
console.log(JSON.stringify({ slug, tables: {} }, null, 2));
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
// The state stamp says "src/monty.gen.ts matches this hash" (it is also
|
|
3571
|
+
// the CAS base) — so stamping ALWAYS travels with a regeneration, or the
|
|
3572
|
+
// heartbeat would read the fresh stamp and leave a stale mirror in place.
|
|
3573
|
+
if (appDir && readSlug(appDir) === slug) {
|
|
3574
|
+
writeGenModule(appDir, body.manifest, { name: body.name, icon: body.icon });
|
|
3575
|
+
writeSchemaState(appDir, body.hash);
|
|
3723
3576
|
}
|
|
3724
|
-
|
|
3725
|
-
console.error(`# ${slug} — manifest hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
|
|
3577
|
+
console.error(`# ${slug} — config hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
|
|
3726
3578
|
console.log(JSON.stringify(body.manifest, null, 2));
|
|
3727
3579
|
}
|
|
3728
3580
|
|
|
3581
|
+
// ── monty public — the shared-functions door ──────────────────────────────
|
|
3582
|
+
// Which server functions answer publicly at /__monty/public/<name> on the
|
|
3583
|
+
// app origin. Door-owned workspace state (never a save ride): `set` replaces
|
|
3584
|
+
// the whole list and takes effect on Live immediately (the host patches the
|
|
3585
|
+
// deploy pointer) and in a running session within one heartbeat.
|
|
3586
|
+
async function publicCmd() {
|
|
3587
|
+
const { host, key } = loadConfig() ?? {};
|
|
3588
|
+
if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
3589
|
+
const appDir = findAppRoot(process.cwd());
|
|
3590
|
+
const verb = rest[0];
|
|
3591
|
+
const flagValue = (name) => {
|
|
3592
|
+
const i = rest.indexOf(name);
|
|
3593
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
3594
|
+
};
|
|
3595
|
+
const slug = flagValue("--app") ?? (appDir ? readSlug(appDir) : null);
|
|
3596
|
+
if (!slug) {
|
|
3597
|
+
fail("INVALID_SLUG", "Usage: monty public [set <names…>|--none] [--app <slug>] — or run it inside an app folder.");
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
if (verb === "set") {
|
|
3601
|
+
const names = rest.includes("--none")
|
|
3602
|
+
? []
|
|
3603
|
+
: rest
|
|
3604
|
+
.slice(1)
|
|
3605
|
+
.filter((a) => !a.startsWith("--") && a !== flagValue("--app"))
|
|
3606
|
+
.flatMap((a) => a.split(","))
|
|
3607
|
+
.map((a) => a.trim())
|
|
3608
|
+
.filter(Boolean);
|
|
3609
|
+
if (names.length === 0 && !rest.includes("--none")) {
|
|
3610
|
+
fail("PUBLIC_USAGE", "Usage: monty public set <name> [<name>…] — the COMPLETE new list (it replaces, never merges). `monty public set --none` closes everything.");
|
|
3611
|
+
}
|
|
3612
|
+
const res = await fetch(`${host}/api/public-fns`, {
|
|
3613
|
+
method: "POST",
|
|
3614
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
3615
|
+
body: JSON.stringify({ slug, publicFns: names }),
|
|
3616
|
+
});
|
|
3617
|
+
const body = await res.json().catch(() => null);
|
|
3618
|
+
if (!res.ok || !body?.ok) {
|
|
3619
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the shared functions failed — is the Monty host reachable?");
|
|
3620
|
+
}
|
|
3621
|
+
if (body.publicFns.length === 0) {
|
|
3622
|
+
console.log(`public: none — "${slug}" answers nothing at /__monty/public`);
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
for (const name of body.publicFns) {
|
|
3626
|
+
const live = body.served?.includes(name);
|
|
3627
|
+
console.log(`public: /__monty/public/${name} — ${live ? "open to the internet NOW; verify signatures in the function" : "declared; opens when a save ships the function"}`);
|
|
3628
|
+
}
|
|
3629
|
+
return;
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
// Default: LIST.
|
|
3633
|
+
const res = await fetch(`${host}/api/public-fns?slug=${slug}`, {
|
|
3634
|
+
headers: { authorization: `Bearer ${key}` },
|
|
3635
|
+
});
|
|
3636
|
+
const body = await res.json().catch(() => null);
|
|
3637
|
+
if (!res.ok || !body?.ok) {
|
|
3638
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the shared functions — check the connection and `monty login`.");
|
|
3639
|
+
}
|
|
3640
|
+
if (!body.publicFns?.length) {
|
|
3641
|
+
console.log(`public: none — "${slug}" answers nothing at /__monty/public (share with \`monty public set <name>\`)`);
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
const registered = new Set(body.fns ?? []);
|
|
3645
|
+
for (const name of body.publicFns) {
|
|
3646
|
+
console.log(`public: /__monty/public/${name}${registered.has(name) ? "" : " — not in the deployed bundle (opens when a save ships it)"}`);
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3729
3650
|
switch (command) {
|
|
3730
3651
|
case "login":
|
|
3731
3652
|
await login();
|
|
@@ -3733,41 +3654,30 @@ switch (command) {
|
|
|
3733
3654
|
case "create":
|
|
3734
3655
|
await create();
|
|
3735
3656
|
break;
|
|
3657
|
+
case "connect":
|
|
3658
|
+
await connect();
|
|
3659
|
+
break;
|
|
3736
3660
|
case "pull":
|
|
3737
3661
|
await pull();
|
|
3738
3662
|
break;
|
|
3739
|
-
case "
|
|
3740
|
-
fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
|
|
3741
|
-
break;
|
|
3742
|
-
case "log":
|
|
3743
|
-
case "versions":
|
|
3663
|
+
case "history":
|
|
3744
3664
|
await versionsLog();
|
|
3745
3665
|
break;
|
|
3666
|
+
case "page":
|
|
3667
|
+
await pageCmd();
|
|
3668
|
+
break;
|
|
3746
3669
|
case "dev":
|
|
3747
3670
|
await dev();
|
|
3748
3671
|
break;
|
|
3749
3672
|
case "logs":
|
|
3750
3673
|
await logs();
|
|
3751
3674
|
break;
|
|
3752
|
-
case "add":
|
|
3753
|
-
await add();
|
|
3754
|
-
break;
|
|
3755
3675
|
case "components":
|
|
3756
|
-
|
|
3757
|
-
components();
|
|
3758
|
-
break;
|
|
3759
|
-
case "docs":
|
|
3760
|
-
await docs();
|
|
3676
|
+
await componentsCmd();
|
|
3761
3677
|
break;
|
|
3762
3678
|
case "current":
|
|
3763
3679
|
current();
|
|
3764
3680
|
break;
|
|
3765
|
-
case "select":
|
|
3766
|
-
select();
|
|
3767
|
-
break;
|
|
3768
|
-
case "apps":
|
|
3769
|
-
apps();
|
|
3770
|
-
break;
|
|
3771
3681
|
case "skills":
|
|
3772
3682
|
installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
|
|
3773
3683
|
console.log("skills: up to date");
|
|
@@ -3781,8 +3691,10 @@ switch (command) {
|
|
|
3781
3691
|
case "typecheck":
|
|
3782
3692
|
typecheckApp();
|
|
3783
3693
|
break;
|
|
3694
|
+
case "style":
|
|
3695
|
+
await styleCheck();
|
|
3696
|
+
break;
|
|
3784
3697
|
case "save":
|
|
3785
|
-
case "deploy":
|
|
3786
3698
|
await deploy();
|
|
3787
3699
|
break;
|
|
3788
3700
|
case "data":
|
|
@@ -3791,6 +3703,9 @@ switch (command) {
|
|
|
3791
3703
|
case "schema":
|
|
3792
3704
|
await schemaCmd();
|
|
3793
3705
|
break;
|
|
3706
|
+
case "public":
|
|
3707
|
+
await publicCmd();
|
|
3708
|
+
break;
|
|
3794
3709
|
case "views":
|
|
3795
3710
|
await views();
|
|
3796
3711
|
break;
|
|
@@ -3800,29 +3715,43 @@ switch (command) {
|
|
|
3800
3715
|
case "secret":
|
|
3801
3716
|
await secret();
|
|
3802
3717
|
break;
|
|
3718
|
+
case "help":
|
|
3719
|
+
case "--help":
|
|
3720
|
+
case "-h":
|
|
3721
|
+
printHelp();
|
|
3722
|
+
process.exit(0);
|
|
3723
|
+
break;
|
|
3803
3724
|
default:
|
|
3804
|
-
|
|
3805
|
-
console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
|
|
3806
|
-
console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
|
|
3807
|
-
console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
|
|
3808
|
-
console.log(" log [slug] the app's source version history (one row per save)");
|
|
3809
|
-
console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app's session, or attach to a running one (live data, auto-auth)");
|
|
3810
|
-
console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, save results)");
|
|
3811
|
-
console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
|
|
3812
|
-
console.log(" components [query] list the curated component catalog");
|
|
3813
|
-
console.log(" docs <name> view a component's source before installing");
|
|
3814
|
-
console.log(" current which app folder am I in?");
|
|
3815
|
-
console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
|
|
3816
|
-
console.log(" apps list local apps (~/.monty/apps + legacy ~/Monty)");
|
|
3817
|
-
console.log(" install install app dependencies");
|
|
3818
|
-
console.log(" build production build (vite, via monty)");
|
|
3819
|
-
console.log(" typecheck typecheck (builds first if needed)");
|
|
3820
|
-
console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main` (build + typecheck gate it)");
|
|
3821
|
-
console.log(" deploy alias of save");
|
|
3822
|
-
console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
|
|
3823
|
-
console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
|
|
3824
|
-
console.log(" views <list|set|update|remove> <table> manage shared saved views on a system record page");
|
|
3825
|
-
console.log(" support <status|enable|disable|submit> opt in and send agent-authored platform reports to Monty");
|
|
3826
|
-
console.log(" skills install/refresh the agent build skill");
|
|
3725
|
+
printHelp();
|
|
3827
3726
|
process.exit(command ? 1 : 0);
|
|
3828
3727
|
}
|
|
3728
|
+
|
|
3729
|
+
function printHelp() {
|
|
3730
|
+
console.log("usage: monty <command> (`monty help` shows this)");
|
|
3731
|
+
console.log("");
|
|
3732
|
+
console.log("start here:");
|
|
3733
|
+
console.log(" login [--host <url>] sign in (opens your browser to authorize)");
|
|
3734
|
+
console.log(" connect <slug> [dir] pull a cloud app into any folder, ready for `monty dev`");
|
|
3735
|
+
console.log(" create <slug> [--name N] [--spa] register a brand-new app (config-only by default)");
|
|
3736
|
+
console.log(" dev [--port N] [--takeover] run the app's session — live data, the workspace follows it");
|
|
3737
|
+
console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main`");
|
|
3738
|
+
console.log(" logs [-n N] [-f] read/follow the running session's log");
|
|
3739
|
+
console.log("");
|
|
3740
|
+
console.log("toolbelt:");
|
|
3741
|
+
console.log(" current which app folder am I in?");
|
|
3742
|
+
console.log(" history [slug] saved-version history, one row per save (like `git log`)");
|
|
3743
|
+
console.log(" pull <slug> [--version H] [--force] restore a source snapshot into the managed home");
|
|
3744
|
+
console.log(" install / build / typecheck / style app lifecycle + the token-vocabulary lint");
|
|
3745
|
+
console.log(" data <verb> [table] [flags] read/write an app's records from the terminal");
|
|
3746
|
+
console.log(" schema [slug] | set <json|-> read/write the app's config, stored in the workspace (validated, CAS)");
|
|
3747
|
+
console.log(" views <list|set|update|remove> <table> manage shared saved views on a record page");
|
|
3748
|
+
console.log(" secret <set|rm> <KEY> per-app server-function secrets (write-only)");
|
|
3749
|
+
console.log(" public [set <names…>|--none] which server functions answer publicly at /__monty/public");
|
|
3750
|
+
console.log(" support <status|enable|disable|submit> opt in to agent-authored platform reports");
|
|
3751
|
+
console.log(" skills install/refresh the agent build skill");
|
|
3752
|
+
console.log("");
|
|
3753
|
+
console.log("custom pages & components (apps with src/):");
|
|
3754
|
+
console.log(" page add <name> scaffold a custom (bespoke) page");
|
|
3755
|
+
console.log(" components [query] | search <q> the curated shadcn catalog — list or search it");
|
|
3756
|
+
console.log(" components add <name...> | docs <name> install curated components / read one's source first");
|
|
3757
|
+
}
|