@montytools/cli 0.5.4 → 0.5.5
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 +349 -385
- package/lib/compile.mjs +3 -3
- package/lib/schemaCodegen.mjs +2 -2
- package/lib/schemaPull.mjs +3 -3
- package/lib/views.mjs +19 -0
- package/package.json +2 -2
- package/skills/monty-build/SKILL.md +90 -105
- package/template/AGENTS.md +6 -5
- package/template/package.json +1 -1
package/bin/monty.mjs
CHANGED
|
@@ -15,16 +15,15 @@ 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 {
|
|
18
|
+
import { compileAppConfig } from "../lib/compile.mjs";
|
|
19
19
|
import { manifestHash } from "../lib/schemaCodegen.mjs";
|
|
20
20
|
import { readSchemaState, schemaPull, writeSchemaState } from "../lib/schemaPull.mjs";
|
|
21
|
-
import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseViewSort, validateViewColumns } from "../lib/views.mjs";
|
|
21
|
+
import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseKanbanFlag, parseViewSort, validateViewColumns } from "../lib/views.mjs";
|
|
22
22
|
|
|
23
23
|
// MONTY_HOME overrides the state root (default ~/.monty): config.json,
|
|
24
24
|
// apps/, and desktop.json all live under it. This is how a second, isolated
|
|
25
25
|
// 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.
|
|
26
|
+
// at ~/.monty-dev so platform development never touches the real state.
|
|
28
27
|
const CONFIG_DIR = process.env.MONTY_HOME
|
|
29
28
|
? resolve(process.env.MONTY_HOME)
|
|
30
29
|
: join(homedir(), ".monty");
|
|
@@ -50,11 +49,9 @@ const ANSI_RE = new RegExp(
|
|
|
50
49
|
// Every app's source lives in one predictable, hidden place: `monty create`
|
|
51
50
|
// registers the app first and the server-minted id names the folder
|
|
52
51
|
// (~/.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.
|
|
52
|
+
// `id` stamped into .monty/app.json is the durable identity. `monty login`
|
|
53
|
+
// provisions the home; --dir overrides per create.
|
|
56
54
|
const MONTY_HOME = join(CONFIG_DIR, "apps");
|
|
57
|
-
const LEGACY_MONTY_HOME = join(homedir(), "Monty");
|
|
58
55
|
|
|
59
56
|
const [, , command, ...rest] = process.argv;
|
|
60
57
|
|
|
@@ -116,22 +113,16 @@ function findMontyrcHost(startDir) {
|
|
|
116
113
|
return null;
|
|
117
114
|
}
|
|
118
115
|
|
|
119
|
-
// Reads config.json
|
|
120
|
-
// next login) or { defaultHost, profiles: { [host]: { key } } }.
|
|
116
|
+
// Reads config.json: { defaultHost, profiles: { [host]: { key } } }.
|
|
121
117
|
function normalizedConfig() {
|
|
122
118
|
let raw = null;
|
|
123
119
|
try {
|
|
124
120
|
raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
125
121
|
} catch { /* not logged in anywhere yet */ }
|
|
126
|
-
if (!raw
|
|
127
|
-
|
|
128
|
-
return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
|
|
122
|
+
if (!raw?.profiles || typeof raw.profiles !== "object") {
|
|
123
|
+
return { defaultHost: null, profiles: {} };
|
|
129
124
|
}
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
defaultHost: legacyHost,
|
|
133
|
-
profiles: raw.key ? { [legacyHost]: { key: raw.key } } : {},
|
|
134
|
-
};
|
|
125
|
+
return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
|
|
135
126
|
}
|
|
136
127
|
|
|
137
128
|
function resolveHost() {
|
|
@@ -344,11 +335,9 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
|
|
|
344
335
|
}
|
|
345
336
|
}
|
|
346
337
|
|
|
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 $(...).
|
|
338
|
+
// ── monty current ──────────────────────────────────────────────────────────
|
|
350
339
|
// The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
|
|
351
|
-
// create and
|
|
340
|
+
// create and connect) or, transitionally, monty.config.ts.
|
|
352
341
|
function isAppRoot(dir) {
|
|
353
342
|
return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
|
|
354
343
|
}
|
|
@@ -400,58 +389,15 @@ function readIdFromConfig(dir) {
|
|
|
400
389
|
}
|
|
401
390
|
}
|
|
402
391
|
|
|
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
392
|
function current() {
|
|
420
393
|
const root = findAppRoot(process.cwd());
|
|
421
394
|
if (!root) {
|
|
422
|
-
fail("NOT_IN_APP",
|
|
395
|
+
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
396
|
}
|
|
424
397
|
console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
|
|
425
398
|
const id = readIdFromConfig(root);
|
|
426
399
|
if (id) console.log(`id: ${id}`);
|
|
427
400
|
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
401
|
}
|
|
456
402
|
|
|
457
403
|
// Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
|
|
@@ -488,7 +434,7 @@ function readSlug(appDir) {
|
|
|
488
434
|
// (Not `monty logs` — that tails the dev shell.)
|
|
489
435
|
async function versionsLog() {
|
|
490
436
|
const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
|
|
491
|
-
if (!slug) fail("NO_SLUG", "Usage: monty
|
|
437
|
+
if (!slug) fail("NO_SLUG", "Usage: monty history [slug] — or run it inside an app folder.");
|
|
492
438
|
const { host, key } = loadConfig();
|
|
493
439
|
if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
494
440
|
const res = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
|
|
@@ -531,7 +477,7 @@ async function pull() {
|
|
|
531
477
|
}
|
|
532
478
|
const app = appsBody.apps.find((a) => a.slug === slug);
|
|
533
479
|
if (!app) {
|
|
534
|
-
fail("APP_NOT_FOUND", `No app "${slug}" in this workspace
|
|
480
|
+
fail("APP_NOT_FOUND", `No app "${slug}" in this workspace — check the app list at ${host}.`);
|
|
535
481
|
}
|
|
536
482
|
// --version <hash-prefix>: restore a specific snapshot from `monty log`
|
|
537
483
|
// instead of the newest one. Prefixes resolve against the history list.
|
|
@@ -548,7 +494,7 @@ async function pull() {
|
|
|
548
494
|
}
|
|
549
495
|
const matches = vbody.versions.filter((v) => v.hash.startsWith(versionFlag));
|
|
550
496
|
if (matches.length === 0) {
|
|
551
|
-
fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty
|
|
497
|
+
fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty history ${slug}\` lists what exists.`);
|
|
552
498
|
}
|
|
553
499
|
if (matches.length > 1) {
|
|
554
500
|
fail("VERSION_AMBIGUOUS", `"${versionFlag}" matches ${matches.length} versions — use more characters of the hash.`);
|
|
@@ -558,7 +504,10 @@ async function pull() {
|
|
|
558
504
|
} else if (!app.sourceHash) {
|
|
559
505
|
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
506
|
}
|
|
561
|
-
|
|
507
|
+
if (!app.id) {
|
|
508
|
+
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.`);
|
|
509
|
+
}
|
|
510
|
+
const target = join(MONTY_HOME, app.id);
|
|
562
511
|
if (existsSync(target) && !rest.includes("--force")) {
|
|
563
512
|
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
513
|
}
|
|
@@ -620,6 +569,130 @@ async function pull() {
|
|
|
620
569
|
console.log("next: `monty install`, then `monty dev`.");
|
|
621
570
|
}
|
|
622
571
|
|
|
572
|
+
// ── monty connect ──────────────────────────────────────────────────────────
|
|
573
|
+
// The one command that turns ANY folder into a working copy of a cloud app:
|
|
574
|
+
// `monty connect <slug> [dir]` (dir defaults to the current folder) pulls
|
|
575
|
+
// the app's files, installs dependencies, stamps identity, and registers
|
|
576
|
+
// the checkout so the desktop's picker sees it. Apps without a source
|
|
577
|
+
// snapshot but with a manifest get the config-only scaffold synced from the
|
|
578
|
+
// registry. Multiple copies of one app on a machine are fine.
|
|
579
|
+
async function connect() {
|
|
580
|
+
const [slugArg, dirArg] = rest.filter((a) => !a.startsWith("--"));
|
|
581
|
+
if (!slugArg || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slugArg)) {
|
|
582
|
+
fail("INVALID_SLUG", "Usage: monty connect <slug> [dir] — pulls the app into dir (default: the current folder, which must be empty).");
|
|
583
|
+
}
|
|
584
|
+
const slug = slugArg;
|
|
585
|
+
const { host, key } = loadConfig();
|
|
586
|
+
if (!key) {
|
|
587
|
+
fail("NOT_LOGGED_IN", `Connecting needs your workspace (${host}). Run \`monty login\` first.`);
|
|
588
|
+
}
|
|
589
|
+
const appsRes = await fetch(`${host}/api/apps`, { headers: { authorization: `Bearer ${key}` } });
|
|
590
|
+
const appsBody = await appsRes.json().catch(() => null);
|
|
591
|
+
if (!appsRes.ok || !appsBody?.ok) {
|
|
592
|
+
fail(appsBody?.code ?? `HTTP_${appsRes.status}`, appsBody?.fix ?? "Could not list workspace apps — check the connection and `monty login`.");
|
|
593
|
+
}
|
|
594
|
+
const app = appsBody.apps.find((a) => a.slug === slug);
|
|
595
|
+
if (!app) {
|
|
596
|
+
fail("APP_NOT_FOUND", `No app "${slug}" in this workspace — check the app list at ${host}.`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const target = resolve(process.cwd(), dirArg ?? ".");
|
|
600
|
+
if (existsSync(target)) {
|
|
601
|
+
if (!statSync(target).isDirectory()) {
|
|
602
|
+
fail("NOT_A_DIRECTORY", `${target} is a file. Point \`monty connect\` at a folder.`);
|
|
603
|
+
}
|
|
604
|
+
if (readdirSync(target).filter((n) => n !== ".DS_Store").length > 0) {
|
|
605
|
+
// A folder that already holds THIS app needs nothing — the working
|
|
606
|
+
// copy is untouched.
|
|
607
|
+
const stamp = readAppJson(target);
|
|
608
|
+
if (stamp && ((app.id && stamp.id === app.id) || stamp.slug === slug)) {
|
|
609
|
+
console.log(`connected: ${slug} -> ${target} (already a copy of this app)`);
|
|
610
|
+
console.log("next: `monty dev`");
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
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}\`.`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (app.sourceHash) {
|
|
618
|
+
console.log(`connect: ${slug} <- ${host}`);
|
|
619
|
+
const res = await fetch(`${host}/api/source?slug=${slug}`, {
|
|
620
|
+
headers: { authorization: `Bearer ${key}` },
|
|
621
|
+
});
|
|
622
|
+
if (!res.ok) {
|
|
623
|
+
const b = await res.json().catch(() => null);
|
|
624
|
+
fail(b?.code ?? `HTTP_${res.status}`, b?.fix ?? "Downloading the snapshot failed — retry.");
|
|
625
|
+
}
|
|
626
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
627
|
+
const hash = createHash("sha256").update(buf).digest("hex");
|
|
628
|
+
if (hash !== app.sourceHash) {
|
|
629
|
+
fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
|
|
630
|
+
}
|
|
631
|
+
// Extract into a sibling staging folder first — a failed extract never
|
|
632
|
+
// leaves a half-written copy. An existing (empty) target keeps its
|
|
633
|
+
// inode: staging contents MOVE in, so a shell sitting in it stays sane.
|
|
634
|
+
const staging = `${target}.connect-tmp`;
|
|
635
|
+
rmSync(staging, { recursive: true, force: true });
|
|
636
|
+
mkdirSync(staging, { recursive: true });
|
|
637
|
+
const tarFile = join(staging, ".source.tar.gz");
|
|
638
|
+
writeFileSync(tarFile, buf);
|
|
639
|
+
const untar = spawnSync("tar", ["-xzf", tarFile, "-C", staging], { stdio: "pipe" });
|
|
640
|
+
rmSync(tarFile, { force: true });
|
|
641
|
+
if (untar.status !== 0) {
|
|
642
|
+
rmSync(staging, { recursive: true, force: true });
|
|
643
|
+
fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
|
|
644
|
+
}
|
|
645
|
+
if (existsSync(target)) {
|
|
646
|
+
for (const name of readdirSync(staging)) renameSync(join(staging, name), join(target, name));
|
|
647
|
+
rmSync(staging, { recursive: true, force: true });
|
|
648
|
+
} else {
|
|
649
|
+
renameSync(staging, target);
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
|
|
653
|
+
writeFileSync(
|
|
654
|
+
join(target, ".env.local"),
|
|
655
|
+
`VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
|
|
656
|
+
);
|
|
657
|
+
} catch {
|
|
658
|
+
console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
|
|
659
|
+
}
|
|
660
|
+
mkdirSync(join(target, ".monty"), { recursive: true });
|
|
661
|
+
writeFileSync(
|
|
662
|
+
join(target, ".monty", "source.json"),
|
|
663
|
+
JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
|
|
664
|
+
);
|
|
665
|
+
} else {
|
|
666
|
+
// No snapshot: the app's config in the workspace IS the app — a
|
|
667
|
+
// config-only scaffold synced from it is a complete working copy.
|
|
668
|
+
console.log(`connect: ${slug} <- ${host} (config-only — the workspace holds the app's config)`);
|
|
669
|
+
if (typeof app.id !== "string" || !/^[a-z0-9]{10,64}$/i.test(app.id)) {
|
|
670
|
+
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.`);
|
|
671
|
+
}
|
|
672
|
+
mkdirSync(target, { recursive: true });
|
|
673
|
+
writeConfigOnlyScaffold(target, { appId: app.id, slug, name: app.name, icon: app.icon });
|
|
674
|
+
try {
|
|
675
|
+
await schemaPull({ appDir: target, host, key, slug, force: true, compileAppConfig, fail });
|
|
676
|
+
} catch {
|
|
677
|
+
console.log("schema: could not sync the app's config yet — `monty schema pull` retries it");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// The snapshot excludes .monty/ — the workspace row is the authority for
|
|
682
|
+
// identity, so stamp it fresh.
|
|
683
|
+
writeAppJson(target, {
|
|
684
|
+
...(app.id ? { id: app.id } : {}),
|
|
685
|
+
slug,
|
|
686
|
+
...(app.name ? { name: app.name } : {}),
|
|
687
|
+
...(app.icon ? { icon: app.icon } : {}),
|
|
688
|
+
});
|
|
689
|
+
if (existsSync(join(target, "package.json"))) {
|
|
690
|
+
installDepsIn(target);
|
|
691
|
+
}
|
|
692
|
+
console.log(`connected: ${slug} -> ${target}`);
|
|
693
|
+
console.log(target === process.cwd() ? "next: `monty dev`" : `next: cd ${dirArg ?? target} && monty dev`);
|
|
694
|
+
}
|
|
695
|
+
|
|
623
696
|
// ── monty create ───────────────────────────────────────────────────────────
|
|
624
697
|
// A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
|
|
625
698
|
// the platform shell renders it. The presence of index.html is the marker
|
|
@@ -628,6 +701,20 @@ function isConfigOnlyApp(appDir) {
|
|
|
628
701
|
return !existsSync(join(appDir, "index.html"));
|
|
629
702
|
}
|
|
630
703
|
|
|
704
|
+
// Literal read of the config's `publicFns: ["a", "b"]` list — the same
|
|
705
|
+
// light-touch parse the SDK's vite plugin uses.
|
|
706
|
+
function readPublicFnsLiteral(appDir) {
|
|
707
|
+
try {
|
|
708
|
+
const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
|
|
709
|
+
const block = /publicFns:\s*\[([^\]]*)\]/m.exec(src)?.[1];
|
|
710
|
+
if (!block) return undefined;
|
|
711
|
+
const names = [...block.matchAll(/["']([a-zA-Z][a-zA-Z0-9_]*)["']/g)].map((m) => m[1]);
|
|
712
|
+
return names.length > 0 ? names : undefined;
|
|
713
|
+
} catch {
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
631
718
|
// Literal read of the config's `schedule` block ({ fn: "cron expr" }) —
|
|
632
719
|
// the same light-touch parse the SDK's vite plugin uses for publicFns.
|
|
633
720
|
// Registry-owned sessions read it this way so the cron ticker works
|
|
@@ -665,12 +752,12 @@ function discoverPages(appDir) {
|
|
|
665
752
|
|
|
666
753
|
const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
|
|
667
754
|
|
|
668
|
-
The app is rendered by the Monty platform from its
|
|
755
|
+
The app is rendered by the Monty platform from its config stored in the workspace —
|
|
669
756
|
tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
|
|
670
757
|
\`settings\`, and \`pages\`. There is no src/, no React, no build.
|
|
671
758
|
|
|
672
759
|
- The schema lives in the workspace, and its door is the schema API:
|
|
673
|
-
read it with \`monty schema\`, change it with \`monty schema set <
|
|
760
|
+
read it with \`monty schema\`, change it with \`monty schema set '<json>'\` (or pipe: \`monty schema set -\`)
|
|
674
761
|
(validated, CAS-guarded, live within seconds). monty.config.ts edits do
|
|
675
762
|
NOT change a workspace-owned app's schema.
|
|
676
763
|
- Formulas are strings in the Monty expression grammar, e.g.
|
|
@@ -682,7 +769,7 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
|
|
|
682
769
|
system record page cannot express. If the page is still one table, start
|
|
683
770
|
with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
|
|
684
771
|
with the record controls from \`@montytools/sdk/ui\`.
|
|
685
|
-
- Need a bespoke page later? \`monty add
|
|
772
|
+
- Need a bespoke page later? \`monty page add\` declares it and upgrades this
|
|
686
773
|
app with a SPA scaffold; \`monty save\` ships the code.
|
|
687
774
|
`;
|
|
688
775
|
|
|
@@ -725,13 +812,6 @@ async function create() {
|
|
|
725
812
|
if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
|
726
813
|
fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens, max 64 chars (e.g. "standup-notes").');
|
|
727
814
|
}
|
|
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
815
|
const name =
|
|
736
816
|
flag("name") ??
|
|
737
817
|
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
@@ -777,7 +857,7 @@ async function create() {
|
|
|
777
857
|
mkdirSync(dirname(target), { recursive: true });
|
|
778
858
|
|
|
779
859
|
// DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
|
|
780
|
-
// platform shell renders it; \`monty add
|
|
860
|
+
// platform shell renders it; \`monty page add\` scaffolds a SPA the moment a
|
|
781
861
|
// bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
|
|
782
862
|
// (\`--config-only\` stays accepted as the now-default no-op).
|
|
783
863
|
if (!rest.includes("--spa")) {
|
|
@@ -910,7 +990,7 @@ async function create() {
|
|
|
910
990
|
}
|
|
911
991
|
|
|
912
992
|
// Build tracking: the /new screen minted an id; recording it here lets
|
|
913
|
-
// `monty
|
|
993
|
+
// `monty save` resolve it and flips the UI into "agent is working" mode.
|
|
914
994
|
const buildId = flag("build");
|
|
915
995
|
if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
|
|
916
996
|
mkdirSync(join(target, ".monty"), { recursive: true });
|
|
@@ -937,14 +1017,17 @@ async function create() {
|
|
|
937
1017
|
// The full app lifecycle goes through the CLI — agents never invoke pnpm,
|
|
938
1018
|
// vite, or tsc directly. Same underlying tools, agent-shaped output, and the
|
|
939
1019
|
// build-before-typecheck ordering handled for you.
|
|
940
|
-
function
|
|
941
|
-
const appDir = requireAppDir("install");
|
|
1020
|
+
function installDepsIn(appDir) {
|
|
942
1021
|
const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
|
|
943
1022
|
run(appDir, "install", [pm, "install"],
|
|
944
1023
|
"Dependency install failed. Read the package manager error above; usually network or a bad package.json edit.");
|
|
945
1024
|
ensureSdk(appDir);
|
|
946
1025
|
}
|
|
947
1026
|
|
|
1027
|
+
function installDeps() {
|
|
1028
|
+
installDepsIn(requireAppDir("install"));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
948
1031
|
function buildApp() {
|
|
949
1032
|
const appDir = requireAppDir("build");
|
|
950
1033
|
run(appDir, "build", ["npx", "vite", "build"],
|
|
@@ -991,7 +1074,7 @@ async function freePort(start) {
|
|
|
991
1074
|
// Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
|
|
992
1075
|
// minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
|
|
993
1076
|
// SDK's vite plugin) and upgrades the app automatically before dev/deploy.
|
|
994
|
-
const MIN_SDK = "0.2.
|
|
1077
|
+
const MIN_SDK = "0.2.5";
|
|
995
1078
|
const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
|
|
996
1079
|
|
|
997
1080
|
function installedSdkVersion(appDir) {
|
|
@@ -1451,17 +1534,16 @@ async function dev() {
|
|
|
1451
1534
|
|
|
1452
1535
|
installSkills({ appDir });
|
|
1453
1536
|
ensureSdk(appDir);
|
|
1454
|
-
//
|
|
1455
|
-
// platform reads NOTHING from the config
|
|
1456
|
-
//
|
|
1457
|
-
// code the bundle imports; `schedule` (a code-door declaration the
|
|
1537
|
+
// Identity comes from the .monty/app.json stamp (written by create and
|
|
1538
|
+
// connect) — the platform reads NOTHING from the config file, which is
|
|
1539
|
+
// just code the bundle imports. `schedule` (a code-door declaration the
|
|
1458
1540
|
// session cron ticker needs) is read literally, the same way the vite
|
|
1459
1541
|
// plugin reads `publicFns`.
|
|
1460
1542
|
const stamp = readAppJson(appDir);
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1543
|
+
if (typeof stamp?.slug !== "string" || !stamp.slug) {
|
|
1544
|
+
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.");
|
|
1545
|
+
}
|
|
1546
|
+
const meta = { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) };
|
|
1465
1547
|
const cfg = loadConfig();
|
|
1466
1548
|
const host = cfg?.host ?? DEFAULT_HOST;
|
|
1467
1549
|
// CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
|
|
@@ -1493,25 +1575,16 @@ async function dev() {
|
|
|
1493
1575
|
let cronTimer = null;
|
|
1494
1576
|
let ended = false;
|
|
1495
1577
|
let registeredOnce = false;
|
|
1496
|
-
//
|
|
1497
|
-
let driftAnnounced = false;
|
|
1498
|
-
// The registry-owned notice prints once per session.
|
|
1578
|
+
// The schema-lives-in-the-workspace notice prints once per session.
|
|
1499
1579
|
let configIgnoredAnnounced = false;
|
|
1500
1580
|
const devStartedAt = Date.now();
|
|
1501
1581
|
const sessionId = `dev_${randomBytes(16).toString("hex")}`;
|
|
1502
1582
|
const buildFile = join(appDir, ".monty", "build");
|
|
1503
1583
|
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
1584
|
// Once per registry change: the manifest hash we last tried to sync the
|
|
1511
1585
|
// local config copy to (successful or refused — never loop on dirty).
|
|
1512
1586
|
let syncAttemptedHash = null;
|
|
1513
1587
|
const configPath = join(appDir, "monty.config.ts");
|
|
1514
|
-
let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
|
|
1515
1588
|
|
|
1516
1589
|
// Advertise this session. The touch timer (not the platform heartbeat,
|
|
1517
1590
|
// which starts minutes late or never when logged out) keeps updatedAt
|
|
@@ -1522,7 +1595,7 @@ async function dev() {
|
|
|
1522
1595
|
version: 1,
|
|
1523
1596
|
cli: CLI_VERSION,
|
|
1524
1597
|
pid: process.pid,
|
|
1525
|
-
vitePid: child
|
|
1598
|
+
vitePid: child?.pid ?? null,
|
|
1526
1599
|
tunnelPid: null,
|
|
1527
1600
|
port,
|
|
1528
1601
|
slug: meta.slug,
|
|
@@ -1548,12 +1621,11 @@ async function dev() {
|
|
|
1548
1621
|
// Trigger on the app's fn-worker; here the CLI matches monty.config.ts
|
|
1549
1622
|
// `schedule` entries against the UTC clock once per minute and invokes the
|
|
1550
1623
|
// fn through the same /__monty/fn runtime (x-monty-schedule marks the
|
|
1551
|
-
// lane, so ctx.viewer matches Live exactly).
|
|
1552
|
-
//
|
|
1553
|
-
// here and never blocks the loop.
|
|
1624
|
+
// lane, so ctx.viewer matches Live exactly). Fire-and-forget: a failing
|
|
1625
|
+
// cron fn prints its instruction here and never blocks the loop.
|
|
1554
1626
|
let lastCronMinute = null;
|
|
1555
1627
|
function cronTick() {
|
|
1556
|
-
const sched =
|
|
1628
|
+
const sched = meta?.schedule;
|
|
1557
1629
|
if (!sched || !loggedIn) return;
|
|
1558
1630
|
const now = new Date();
|
|
1559
1631
|
const minute = Math.floor(now.getTime() / 60_000);
|
|
@@ -1579,22 +1651,6 @@ async function dev() {
|
|
|
1579
1651
|
}
|
|
1580
1652
|
cronTimer = setInterval(cronTick, 20_000);
|
|
1581
1653
|
|
|
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
1654
|
async function clearDevSession(timeoutMs = 2000) {
|
|
1599
1655
|
if (cfg?.key) {
|
|
1600
1656
|
try {
|
|
@@ -1639,7 +1695,6 @@ async function dev() {
|
|
|
1639
1695
|
|
|
1640
1696
|
async function heartbeat(originUrl, { claim = false } = {}) {
|
|
1641
1697
|
if (ended) return false; // shutdown already ran — no side effects
|
|
1642
|
-
await refreshSchemaIfChanged();
|
|
1643
1698
|
try {
|
|
1644
1699
|
// Re-read the key EVERY beat: the desktop (or a fresh `monty login`)
|
|
1645
1700
|
// may have replaced an expired key while this session runs — the
|
|
@@ -1659,23 +1714,9 @@ async function dev() {
|
|
|
1659
1714
|
// owner. BOUNDED so a never-registering session (broken network)
|
|
1660
1715
|
// can't steal the lock from a newer active session forever.
|
|
1661
1716
|
claim: claim || (!registeredOnce && Date.now() - devStartedAt < 90_000),
|
|
1662
|
-
name:
|
|
1663
|
-
icon:
|
|
1717
|
+
name: meta.name,
|
|
1718
|
+
icon: meta.icon,
|
|
1664
1719
|
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
1720
|
}),
|
|
1680
1721
|
signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
|
|
1681
1722
|
});
|
|
@@ -1685,11 +1726,6 @@ async function dev() {
|
|
|
1685
1726
|
stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
|
|
1686
1727
|
return false;
|
|
1687
1728
|
}
|
|
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
1729
|
console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
|
|
1694
1730
|
// A dead key is a SIGNED-OUT session — advertise it so the desktop
|
|
1695
1731
|
// (which owns the session) can surface sign-in instead of letting
|
|
@@ -1699,38 +1735,15 @@ async function dev() {
|
|
|
1699
1735
|
}
|
|
1700
1736
|
return false;
|
|
1701
1737
|
}
|
|
1702
|
-
if (data?.manifestDrift) {
|
|
1703
|
-
// The session registered, but the config push was HELD: the stored
|
|
1704
|
-
// schema changed since this checkout last synced (another editor).
|
|
1705
|
-
if (!driftAnnounced) {
|
|
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
|
-
}
|
|
1715
1738
|
if (!registeredOnce) {
|
|
1716
1739
|
registeredOnce = true;
|
|
1717
1740
|
sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1718
1741
|
} else {
|
|
1719
1742
|
sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
|
|
1720
1743
|
}
|
|
1721
|
-
if (
|
|
1744
|
+
if (!configIgnoredAnnounced) {
|
|
1722
1745
|
configIgnoredAnnounced = true;
|
|
1723
|
-
console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set <
|
|
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 */ }
|
|
1746
|
+
console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set '<json>'` (or pipe: `monty schema set -`).");
|
|
1734
1747
|
}
|
|
1735
1748
|
// The local config copy follows the registry: when the stored manifest
|
|
1736
1749
|
// moved (another editor, the schema door) and the local file is clean,
|
|
@@ -1850,26 +1863,8 @@ async function dev() {
|
|
|
1850
1863
|
|
|
1851
1864
|
if (configOnly) {
|
|
1852
1865
|
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
|
-
);
|
|
1866
|
+
console.log("ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`");
|
|
1858
1867
|
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
1868
|
}
|
|
1874
1869
|
|
|
1875
1870
|
let announced = false;
|
|
@@ -2124,7 +2119,7 @@ function startTunnel(port, onUrlChange, onOutput) {
|
|
|
2124
2119
|
});
|
|
2125
2120
|
}
|
|
2126
2121
|
|
|
2127
|
-
// ── monty
|
|
2122
|
+
// ── monty components / page ────────────────────────────────────────────────
|
|
2128
2123
|
// Wraps the shadcn CLI behind the curated catalog: agents ask for a
|
|
2129
2124
|
// capability by plain name ("kanban") and get the blessed, theme-compatible
|
|
2130
2125
|
// implementation. Only catalog registries and core shadcn resolve.
|
|
@@ -2132,7 +2127,7 @@ function startTunnel(port, onUrlChange, onOutput) {
|
|
|
2132
2127
|
function requireAppDir(cmd) {
|
|
2133
2128
|
const appDir = findAppRoot(process.cwd());
|
|
2134
2129
|
if (!appDir) {
|
|
2135
|
-
fail("NOT_A_MONTY_APP", `Not inside a Monty app.
|
|
2130
|
+
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
2131
|
}
|
|
2137
2132
|
return appDir;
|
|
2138
2133
|
}
|
|
@@ -2150,7 +2145,7 @@ function resolveComponent(name) {
|
|
|
2150
2145
|
return name;
|
|
2151
2146
|
}
|
|
2152
2147
|
|
|
2153
|
-
// ── monty add
|
|
2148
|
+
// ── monty page add <name> ──────────────────────────────────────────────────
|
|
2154
2149
|
// Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
|
|
2155
2150
|
// use (config-only apps gain src/ + vite from the template — their
|
|
2156
2151
|
// monty.config.ts and AGENTS.md stay untouched), declares
|
|
@@ -2204,7 +2199,7 @@ async function declarePageThroughDoor(appDir, pageName) {
|
|
|
2204
2199
|
|
|
2205
2200
|
async function addPage(appDir, pageName) {
|
|
2206
2201
|
if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
|
|
2207
|
-
fail("INVALID_PAGE", 'Usage: monty add
|
|
2202
|
+
fail("INVALID_PAGE", 'Usage: monty page add <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
|
|
2208
2203
|
}
|
|
2209
2204
|
const configPath = join(appDir, "monty.config.ts");
|
|
2210
2205
|
if (!existsSync(configPath)) {
|
|
@@ -2291,7 +2286,7 @@ async function addPage(appDir, pageName) {
|
|
|
2291
2286
|
if (existsSync(starter)) rmSync(starter);
|
|
2292
2287
|
appendFileSync(
|
|
2293
2288
|
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
|
|
2289
|
+
`\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
2290
|
);
|
|
2296
2291
|
}
|
|
2297
2292
|
|
|
@@ -2372,16 +2367,17 @@ function pageComponentName(pageName) {
|
|
|
2372
2367
|
return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
|
|
2373
2368
|
}
|
|
2374
2369
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
if (names[0] === "page") {
|
|
2382
|
-
await addPage(appDir, names[1]);
|
|
2383
|
-
return;
|
|
2370
|
+
// The page namespace: `monty page add <name>` scaffolds a bespoke page
|
|
2371
|
+
// (declare-first through the schema door).
|
|
2372
|
+
async function pageCmd() {
|
|
2373
|
+
const [sub, name] = rest.filter((a) => !a.startsWith("--"));
|
|
2374
|
+
if (sub !== "add") {
|
|
2375
|
+
fail("INVALID_PAGE", 'Usage: monty page add <name> — scaffold a custom page (e.g. `monty page add reports`).');
|
|
2384
2376
|
}
|
|
2377
|
+
await addPage(requireAppDir("page"), name);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
async function addComponents(appDir, names) {
|
|
2385
2381
|
const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
|
|
2386
2382
|
|
|
2387
2383
|
// --overwrite so shadcn never halts on a per-file prompt (an aborted prompt
|
|
@@ -2423,31 +2419,50 @@ async function add() {
|
|
|
2423
2419
|
console.log(`added: ${items.join(", ")} -> src/components (already themed; import and compose)`);
|
|
2424
2420
|
}
|
|
2425
2421
|
|
|
2426
|
-
|
|
2427
|
-
|
|
2422
|
+
// The component namespace: `monty components` lists/searches the curated
|
|
2423
|
+
// catalog, `components add <name...>` installs, `components docs <name>`
|
|
2424
|
+
// prints a component's source. (`search` is the explicit search subcommand;
|
|
2425
|
+
// a bare query searches too.)
|
|
2426
|
+
async function componentsCmd() {
|
|
2427
|
+
const args = rest.filter((a) => !a.startsWith("--"));
|
|
2428
|
+
const [sub, ...tail] = args;
|
|
2429
|
+
if (sub === "add") {
|
|
2430
|
+
if (tail.length === 0) {
|
|
2431
|
+
fail("NO_COMPONENT", "Usage: monty components add <name...> — `monty components` lists the catalog; core shadcn components install by bare name.");
|
|
2432
|
+
}
|
|
2433
|
+
await addComponents(requireAppDir("components"), tail);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (sub === "docs") {
|
|
2437
|
+
if (!tail[0]) {
|
|
2438
|
+
fail("NO_COMPONENT", "Usage: monty components docs <name> — `monty components` lists the catalog.");
|
|
2439
|
+
}
|
|
2440
|
+
componentDocs(requireAppDir("components"), tail[0]);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
listComponents((sub === "search" ? tail : args).join(" ").toLowerCase());
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function listComponents(query) {
|
|
2428
2447
|
const entries = Object.entries(CATALOG).filter(
|
|
2429
2448
|
([name, { item, use }]) => !query || `${name} ${item} ${use}`.toLowerCase().includes(query),
|
|
2430
2449
|
);
|
|
2431
2450
|
const width = Math.max(...Object.keys(CATALOG).map((n) => n.length));
|
|
2432
2451
|
const itemWidth = Math.max(...Object.values(CATALOG).map((c) => c.item.length));
|
|
2433
|
-
console.log(`components: ${entries.length} curated (install with \`monty add <name>\`)`);
|
|
2452
|
+
console.log(`components: ${entries.length} curated (install with \`monty components add <name>\`)`);
|
|
2434
2453
|
for (const [name, { item, use }] of entries) {
|
|
2435
2454
|
console.log(` ${name.padEnd(width)} ${item.padEnd(itemWidth)} ${use}`);
|
|
2436
2455
|
}
|
|
2437
2456
|
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");
|
|
2457
|
+
console.log("docs: `monty components docs <name>` shows a component's source before installing");
|
|
2439
2458
|
}
|
|
2440
2459
|
|
|
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
|
-
}
|
|
2460
|
+
function componentDocs(appDir, name) {
|
|
2447
2461
|
run(appDir, "docs", ["pnpm", "dlx", "shadcn@latest", "view", resolveComponent(name)],
|
|
2448
2462
|
`Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
|
|
2449
2463
|
}
|
|
2450
2464
|
|
|
2465
|
+
|
|
2451
2466
|
// ── monty secret ─────────────────────────────────────────────────────────
|
|
2452
2467
|
// `monty secret set KEY [value]` / `monty secret rm KEY` — per-app
|
|
2453
2468
|
// server-function secrets. The value goes to Cloudflare's per-script secrets
|
|
@@ -2455,12 +2470,11 @@ async function docs() {
|
|
|
2455
2470
|
// readable back. Read from the arg, then a TTY prompt, then stdin (piping).
|
|
2456
2471
|
async function secret() {
|
|
2457
2472
|
const appDir = requireAppDir("secret");
|
|
2458
|
-
// Identity comes from the stamp when it exists — no config compile for
|
|
2459
|
-
// one slug read.
|
|
2460
2473
|
const stamped = readAppJson(appDir);
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2474
|
+
if (typeof stamped?.slug !== "string" || !stamped.slug) {
|
|
2475
|
+
fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here first.");
|
|
2476
|
+
}
|
|
2477
|
+
const meta = { slug: stamped.slug };
|
|
2464
2478
|
const config = loadConfig();
|
|
2465
2479
|
if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
|
|
2466
2480
|
const [sub, name] = rest.filter((a) => !a.startsWith("-"));
|
|
@@ -2498,10 +2512,11 @@ async function secret() {
|
|
|
2498
2512
|
}
|
|
2499
2513
|
|
|
2500
2514
|
// ── monty save ─────────────────────────────────────────────────────────────
|
|
2501
|
-
// Push the working copy to the cloud copy, like `git push main
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2515
|
+
// Push the working copy to the cloud copy, like `git push main` (build +
|
|
2516
|
+
// typecheck gate every save, then one multipart POST). A save ships
|
|
2517
|
+
// IMPLEMENTATION ONLY — the app's config lives in the workspace and lands
|
|
2518
|
+
// through the doors; the config file is never compiled here. The optional
|
|
2519
|
+
// message rides the meta so the platform can narrate the save later.
|
|
2505
2520
|
async function deploy() {
|
|
2506
2521
|
const appDir = requireAppDir(command);
|
|
2507
2522
|
ensureSdk(appDir);
|
|
@@ -2521,40 +2536,36 @@ async function deploy() {
|
|
|
2521
2536
|
fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
|
|
2522
2537
|
}
|
|
2523
2538
|
|
|
2524
|
-
// 1)
|
|
2525
|
-
//
|
|
2526
|
-
|
|
2527
|
-
const
|
|
2528
|
-
|
|
2539
|
+
// 1) Identity from the stamp; code-door declarations (publicFns,
|
|
2540
|
+
// schedule) are read literally from the config file, the same way the
|
|
2541
|
+
// vite plugin reads them.
|
|
2542
|
+
const stamp = readAppJson(appDir);
|
|
2543
|
+
if (typeof stamp?.slug !== "string" || !stamp.slug) {
|
|
2544
|
+
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.");
|
|
2545
|
+
}
|
|
2546
|
+
const meta = {
|
|
2547
|
+
slug: stamp.slug,
|
|
2548
|
+
name: stamp.name ?? stamp.slug,
|
|
2549
|
+
icon: stamp.icon,
|
|
2550
|
+
publicFns: readPublicFnsLiteral(appDir),
|
|
2551
|
+
schedule: readScheduleLiteral(appDir),
|
|
2552
|
+
};
|
|
2529
2553
|
// `monty save "what changed"` — the message rides the meta for the
|
|
2530
2554
|
// platform to render as this save's Activity row.
|
|
2531
2555
|
if (message) meta.message = message;
|
|
2532
2556
|
|
|
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.
|
|
2557
|
+
// CONFIG-ONLY apps have no bundle — the platform shell renders them; the
|
|
2558
|
+
// save carries the source snapshot only.
|
|
2547
2559
|
const configOnly = isConfigOnlyApp(appDir);
|
|
2548
|
-
if (
|
|
2560
|
+
if (configOnly) {
|
|
2561
|
+
meta.configOnly = true;
|
|
2562
|
+
} else {
|
|
2549
2563
|
// 2) Fail fast locally before any upload. Build FIRST — it also generates
|
|
2550
2564
|
// src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
|
|
2551
2565
|
run(appDir, "build", ["npx", "vite", "build"],
|
|
2552
2566
|
"The production build failed. Read the vite error above; it names the file to fix.");
|
|
2553
2567
|
run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
|
|
2554
2568
|
"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
2569
|
}
|
|
2559
2570
|
|
|
2560
2571
|
// 3) Multipart POST to the host.
|
|
@@ -2588,9 +2599,7 @@ async function deploy() {
|
|
|
2588
2599
|
`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
2600
|
}
|
|
2590
2601
|
}
|
|
2591
|
-
|
|
2592
|
-
// it); meta.datasets is the additive split newer hosts classify with.
|
|
2593
|
-
meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
|
|
2602
|
+
if (serverBundle.fns.length > 0) meta.fns = serverBundle.fns;
|
|
2594
2603
|
if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
|
|
2595
2604
|
form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
|
|
2596
2605
|
const bundled = [];
|
|
@@ -2618,7 +2627,7 @@ async function deploy() {
|
|
|
2618
2627
|
// only the minified bundle and the sole copy of the app's code is this
|
|
2619
2628
|
// folder — delete it and the source is gone forever. The snapshot is what
|
|
2620
2629
|
// `monty pull <slug>` restores on any machine, and the publish lands in
|
|
2621
|
-
// the same version history as `monty
|
|
2630
|
+
// the same version history as `monty save`.
|
|
2622
2631
|
let sourceHash = null;
|
|
2623
2632
|
{
|
|
2624
2633
|
const packed = packSource(appDir);
|
|
@@ -2633,13 +2642,6 @@ async function deploy() {
|
|
|
2633
2642
|
console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
|
|
2634
2643
|
}
|
|
2635
2644
|
}
|
|
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
2645
|
form.set("monty", JSON.stringify(meta));
|
|
2644
2646
|
let total = 0;
|
|
2645
2647
|
for (const file of files) {
|
|
@@ -2650,7 +2652,7 @@ async function deploy() {
|
|
|
2650
2652
|
}
|
|
2651
2653
|
console.log(
|
|
2652
2654
|
configOnly
|
|
2653
|
-
? `upload: config-only (
|
|
2655
|
+
? `upload: config-only (source snapshot, no bundle) -> ${config.host}/api/deploy`
|
|
2654
2656
|
: `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
|
|
2655
2657
|
);
|
|
2656
2658
|
const res = await fetch(`${config.host}/api/deploy`, {
|
|
@@ -2660,22 +2662,10 @@ async function deploy() {
|
|
|
2660
2662
|
});
|
|
2661
2663
|
const body = await res.json().catch(() => null);
|
|
2662
2664
|
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
2665
|
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
|
|
2667
2666
|
}
|
|
2668
2667
|
console.log(`origin: ${body.origin}`);
|
|
2669
2668
|
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
2669
|
// Stamp what was published — pull uses this to tell "unchanged since last
|
|
2680
2670
|
// sync" from "locally modified".
|
|
2681
2671
|
if (sourceHash) {
|
|
@@ -2777,25 +2767,6 @@ function discoverFnExports(serverEntry) {
|
|
|
2777
2767
|
return [...names];
|
|
2778
2768
|
}
|
|
2779
2769
|
|
|
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
2770
|
function run(cwd, label, argv, fixOnFail) {
|
|
2800
2771
|
console.log(`${label}: ${argv.join(" ")}`);
|
|
2801
2772
|
// stdin ignored: interactive prompts (e.g. shadcn's per-file overwrite
|
|
@@ -2917,10 +2888,7 @@ function resolveDataApp() {
|
|
|
2917
2888
|
const root = findAppRoot(process.cwd());
|
|
2918
2889
|
const slug = explicit ?? (root ? readSlugFromConfig(root) : null);
|
|
2919
2890
|
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.");
|
|
2891
|
+
fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder.");
|
|
2924
2892
|
}
|
|
2925
2893
|
return slug;
|
|
2926
2894
|
}
|
|
@@ -3056,33 +3024,17 @@ async function data() {
|
|
|
3056
3024
|
const [verb, table, id] = dataPositionals();
|
|
3057
3025
|
|
|
3058
3026
|
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
|
-
}
|
|
3027
|
+
// Shape comes from the WORKSPACE — the stored schema the platform
|
|
3028
|
+
// validates against (derived from the app's config).
|
|
3029
|
+
const app = resolveDataApp();
|
|
3030
|
+
const auth = await workspaceAuth();
|
|
3031
|
+
const result = await callRecords(
|
|
3032
|
+
"query",
|
|
3033
|
+
"schema",
|
|
3034
|
+
table !== undefined ? { app, table } : { app },
|
|
3035
|
+
auth,
|
|
3036
|
+
);
|
|
3037
|
+
printJson({ app, ...result });
|
|
3086
3038
|
return;
|
|
3087
3039
|
}
|
|
3088
3040
|
|
|
@@ -3267,7 +3219,7 @@ async function data() {
|
|
|
3267
3219
|
// The CLI accepts concise filters, then stores the shell's existing
|
|
3268
3220
|
// { filters, sort, hidden } ViewConfig shape unchanged.
|
|
3269
3221
|
|
|
3270
|
-
const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name"]);
|
|
3222
|
+
const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name", "kanban"]);
|
|
3271
3223
|
|
|
3272
3224
|
function viewPositionals() {
|
|
3273
3225
|
const out = [];
|
|
@@ -3285,10 +3237,11 @@ function viewPositionals() {
|
|
|
3285
3237
|
function viewsUsage() {
|
|
3286
3238
|
console.log("usage: monty views <list|set|update|remove> <table> [name] [flags]");
|
|
3287
3239
|
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]");
|
|
3240
|
+
console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--kanban field|none] [--app slug]");
|
|
3241
|
+
console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--kanban field|none] [--app slug]");
|
|
3290
3242
|
console.log(" remove <table> <name> [--app slug]");
|
|
3291
3243
|
console.log("filter values: null = empty; scalar = exact; array = any listed value; {\"contains\":\"text\"}; {\"min\":0,\"max\":100}");
|
|
3244
|
+
console.log("kanban: the view renders as lanes over the select field's values; \"none\" makes it a table");
|
|
3292
3245
|
console.log("example: monty views set leads \"Evaluate\" --filter '{\"pipelineId\":null}'");
|
|
3293
3246
|
process.exit(1);
|
|
3294
3247
|
}
|
|
@@ -3313,10 +3266,9 @@ function viewName(input) {
|
|
|
3313
3266
|
async function viewContext(table) {
|
|
3314
3267
|
const app = resolveDataApp();
|
|
3315
3268
|
const auth = await workspaceAuth();
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
}
|
|
3269
|
+
// Nothing stored yet is just an empty config — the table lookup below
|
|
3270
|
+
// says what's actually missing.
|
|
3271
|
+
const manifest = (await callConvex("query", "platform:appManifest", { slug: app }, auth)) ?? { tables: {} };
|
|
3320
3272
|
const tableSpec = manifest.tables?.[table];
|
|
3321
3273
|
if (!tableSpec) {
|
|
3322
3274
|
fail("NO_SUCH_TABLE", `App "${app}" has no table "${table}". Tables: ${Object.keys(manifest.tables ?? {}).join(", ") || "(none)"}.`);
|
|
@@ -3343,6 +3295,8 @@ function requestedViewConfig(fields) {
|
|
|
3343
3295
|
sort: parseViewSort(flag("sort")),
|
|
3344
3296
|
hidden: parseHiddenColumns(flag("hide")),
|
|
3345
3297
|
};
|
|
3298
|
+
const kanban = parseKanbanFlag(flag("kanban"));
|
|
3299
|
+
if (kanban) Object.assign(config, kanban);
|
|
3346
3300
|
return validateViewColumns(config, fields);
|
|
3347
3301
|
} catch (error) {
|
|
3348
3302
|
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
@@ -3355,6 +3309,7 @@ function requestedViewPatch() {
|
|
|
3355
3309
|
...(rest.includes("--filter") ? { filters: normalizeViewFilters(parseJsonFlag("filter")) } : {}),
|
|
3356
3310
|
...(rest.includes("--sort") ? { sort: parseViewSort(flag("sort")) } : {}),
|
|
3357
3311
|
...(rest.includes("--hide") ? { hidden: parseHiddenColumns(flag("hide")) } : {}),
|
|
3312
|
+
...(rest.includes("--kanban") ? { kanban: parseKanbanFlag(flag("kanban")) } : {}),
|
|
3358
3313
|
};
|
|
3359
3314
|
} catch (error) {
|
|
3360
3315
|
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
@@ -3405,7 +3360,7 @@ async function views() {
|
|
|
3405
3360
|
}
|
|
3406
3361
|
const patch = requestedViewPatch();
|
|
3407
3362
|
if (nextName === name && Object.keys(patch).length === 0) {
|
|
3408
|
-
fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, or --
|
|
3363
|
+
fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, --hide, or --kanban. Omitted properties stay unchanged.");
|
|
3409
3364
|
}
|
|
3410
3365
|
const config = checkedViewConfig(mergeViewConfig(existing.config, patch), fields);
|
|
3411
3366
|
await callConvex("mutation", "platform:saveView", { app, page: table, name: nextName, config }, auth);
|
|
@@ -3635,7 +3590,7 @@ if (command !== "dev" && command !== "logs" && command !== "support") {
|
|
|
3635
3590
|
// The app's data half (tables, field algebra, metrics, settings, pages)
|
|
3636
3591
|
// lives ONLY in the workspace. `monty schema [slug]` prints the stored
|
|
3637
3592
|
// manifest as JSON (and stamps the CAS base); edit that JSON and
|
|
3638
|
-
// `monty schema set <
|
|
3593
|
+
// `monty schema set '<json>'` (or `set -` piped) writes it back|->` writes it back through the one landing —
|
|
3639
3594
|
// validated server-side, additive-only by default, CAS against what you
|
|
3640
3595
|
// read. `monty schema pull` (legacy) regenerates monty.config.ts.
|
|
3641
3596
|
async function schemaCmd() {
|
|
@@ -3664,24 +3619,28 @@ async function schemaCmd() {
|
|
|
3664
3619
|
}
|
|
3665
3620
|
|
|
3666
3621
|
if (verb === "set") {
|
|
3622
|
+
// The config is passed as JSON, never a file — nothing to leave behind
|
|
3623
|
+
// in the app folder. `-` reads stdin for configs too big for an arg.
|
|
3667
3624
|
const target = rest[1];
|
|
3668
3625
|
if (!target) {
|
|
3669
|
-
fail("SCHEMA_USAGE", "Usage: monty schema set <
|
|
3626
|
+
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
3627
|
}
|
|
3671
|
-
let raw;
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3628
|
+
let raw = target;
|
|
3629
|
+
if (target === "-") {
|
|
3630
|
+
try {
|
|
3631
|
+
raw = readFileSync(0, "utf8");
|
|
3632
|
+
} catch {
|
|
3633
|
+
fail("SCHEMA_USAGE", "Could not read stdin. Pipe the config JSON in: `monty schema | <edit> | monty schema set -`.");
|
|
3634
|
+
}
|
|
3676
3635
|
}
|
|
3677
3636
|
let manifest;
|
|
3678
3637
|
try {
|
|
3679
3638
|
manifest = JSON.parse(raw);
|
|
3680
3639
|
} catch {
|
|
3681
|
-
fail("
|
|
3640
|
+
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
3641
|
}
|
|
3683
3642
|
const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
|
|
3684
|
-
if (!slug) fail("INVALID_SLUG", "The
|
|
3643
|
+
if (!slug) fail("INVALID_SLUG", "The config carries no slug and this is not an app folder — set `slug` in the JSON.");
|
|
3685
3644
|
// CAS: prove which stored manifest this edit was based on (stamped by
|
|
3686
3645
|
// the last `monty schema` read in this folder). Absent = trusting push.
|
|
3687
3646
|
const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
|
|
@@ -3700,7 +3659,7 @@ async function schemaCmd() {
|
|
|
3700
3659
|
if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
|
|
3701
3660
|
console.log(`schema drift (remote changes):\n${body.summary}`);
|
|
3702
3661
|
}
|
|
3703
|
-
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the
|
|
3662
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the config failed — is the Monty host reachable?");
|
|
3704
3663
|
}
|
|
3705
3664
|
if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
|
|
3706
3665
|
console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
|
|
@@ -3708,7 +3667,7 @@ async function schemaCmd() {
|
|
|
3708
3667
|
}
|
|
3709
3668
|
|
|
3710
3669
|
// Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
|
|
3711
|
-
// JSON (
|
|
3670
|
+
// JSON (edit it, then `monty schema set` the whole document back).
|
|
3712
3671
|
const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
|
|
3713
3672
|
if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
|
|
3714
3673
|
const res = await fetch(`${host}/api/schema?slug=${slug}`, {
|
|
@@ -3716,13 +3675,17 @@ async function schemaCmd() {
|
|
|
3716
3675
|
});
|
|
3717
3676
|
const body = await res.json().catch(() => null);
|
|
3718
3677
|
if (!res.ok || !body?.ok) {
|
|
3719
|
-
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the
|
|
3678
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the config — check the connection and `monty login`.");
|
|
3720
3679
|
}
|
|
3721
3680
|
if (body.manifest === null) {
|
|
3722
|
-
|
|
3681
|
+
// Nothing stored yet is just an empty config — hand it back so the
|
|
3682
|
+
// agent can fill it in and `monty schema set` it.
|
|
3683
|
+
console.error(`# ${slug} — empty config (nothing stored yet; "monty schema set" declares it)`);
|
|
3684
|
+
console.log(JSON.stringify({ slug, tables: {} }, null, 2));
|
|
3685
|
+
return;
|
|
3723
3686
|
}
|
|
3724
3687
|
if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
|
|
3725
|
-
console.error(`# ${slug} —
|
|
3688
|
+
console.error(`# ${slug} — config hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
|
|
3726
3689
|
console.log(JSON.stringify(body.manifest, null, 2));
|
|
3727
3690
|
}
|
|
3728
3691
|
|
|
@@ -3733,41 +3696,30 @@ switch (command) {
|
|
|
3733
3696
|
case "create":
|
|
3734
3697
|
await create();
|
|
3735
3698
|
break;
|
|
3699
|
+
case "connect":
|
|
3700
|
+
await connect();
|
|
3701
|
+
break;
|
|
3736
3702
|
case "pull":
|
|
3737
3703
|
await pull();
|
|
3738
3704
|
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":
|
|
3705
|
+
case "history":
|
|
3744
3706
|
await versionsLog();
|
|
3745
3707
|
break;
|
|
3708
|
+
case "page":
|
|
3709
|
+
await pageCmd();
|
|
3710
|
+
break;
|
|
3746
3711
|
case "dev":
|
|
3747
3712
|
await dev();
|
|
3748
3713
|
break;
|
|
3749
3714
|
case "logs":
|
|
3750
3715
|
await logs();
|
|
3751
3716
|
break;
|
|
3752
|
-
case "add":
|
|
3753
|
-
await add();
|
|
3754
|
-
break;
|
|
3755
3717
|
case "components":
|
|
3756
|
-
|
|
3757
|
-
components();
|
|
3758
|
-
break;
|
|
3759
|
-
case "docs":
|
|
3760
|
-
await docs();
|
|
3718
|
+
await componentsCmd();
|
|
3761
3719
|
break;
|
|
3762
3720
|
case "current":
|
|
3763
3721
|
current();
|
|
3764
3722
|
break;
|
|
3765
|
-
case "select":
|
|
3766
|
-
select();
|
|
3767
|
-
break;
|
|
3768
|
-
case "apps":
|
|
3769
|
-
apps();
|
|
3770
|
-
break;
|
|
3771
3723
|
case "skills":
|
|
3772
3724
|
installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
|
|
3773
3725
|
console.log("skills: up to date");
|
|
@@ -3782,7 +3734,6 @@ switch (command) {
|
|
|
3782
3734
|
typecheckApp();
|
|
3783
3735
|
break;
|
|
3784
3736
|
case "save":
|
|
3785
|
-
case "deploy":
|
|
3786
3737
|
await deploy();
|
|
3787
3738
|
break;
|
|
3788
3739
|
case "data":
|
|
@@ -3800,29 +3751,42 @@ switch (command) {
|
|
|
3800
3751
|
case "secret":
|
|
3801
3752
|
await secret();
|
|
3802
3753
|
break;
|
|
3754
|
+
case "help":
|
|
3755
|
+
case "--help":
|
|
3756
|
+
case "-h":
|
|
3757
|
+
printHelp();
|
|
3758
|
+
process.exit(0);
|
|
3759
|
+
break;
|
|
3803
3760
|
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");
|
|
3761
|
+
printHelp();
|
|
3827
3762
|
process.exit(command ? 1 : 0);
|
|
3828
3763
|
}
|
|
3764
|
+
|
|
3765
|
+
function printHelp() {
|
|
3766
|
+
console.log("usage: monty <command> (`monty help` shows this)");
|
|
3767
|
+
console.log("");
|
|
3768
|
+
console.log("start here:");
|
|
3769
|
+
console.log(" login [--host <url>] sign in (opens your browser to authorize)");
|
|
3770
|
+
console.log(" connect <slug> [dir] pull a cloud app into any folder, ready for `monty dev`");
|
|
3771
|
+
console.log(" create <slug> [--name N] [--spa] register a brand-new app (config-only by default)");
|
|
3772
|
+
console.log(" dev [--port N] [--takeover] run the app's session — live data, the workspace follows it");
|
|
3773
|
+
console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main`");
|
|
3774
|
+
console.log(" logs [-n N] [-f] read/follow the running session's log");
|
|
3775
|
+
console.log("");
|
|
3776
|
+
console.log("toolbelt:");
|
|
3777
|
+
console.log(" current which app folder am I in?");
|
|
3778
|
+
console.log(" history [slug] saved-version history, one row per save (like `git log`)");
|
|
3779
|
+
console.log(" pull <slug> [--version H] [--force] restore a source snapshot into the managed home");
|
|
3780
|
+
console.log(" install / build / typecheck app lifecycle (pnpm/npm picked for you)");
|
|
3781
|
+
console.log(" data <verb> [table] [flags] read/write an app's records from the terminal");
|
|
3782
|
+
console.log(" schema [slug] | set <json|-> read/write the app's config, stored in the workspace (validated, CAS)");
|
|
3783
|
+
console.log(" views <list|set|update|remove> <table> manage shared saved views on a record page");
|
|
3784
|
+
console.log(" secret <set|rm> <KEY> per-app server-function secrets (write-only)");
|
|
3785
|
+
console.log(" support <status|enable|disable|submit> opt in to agent-authored platform reports");
|
|
3786
|
+
console.log(" skills install/refresh the agent build skill");
|
|
3787
|
+
console.log("");
|
|
3788
|
+
console.log("custom pages & components (apps with src/):");
|
|
3789
|
+
console.log(" page add <name> scaffold a custom (bespoke) page");
|
|
3790
|
+
console.log(" components [query] | search <q> the curated shadcn catalog — list or search it");
|
|
3791
|
+
console.log(" components add <name...> | docs <name> install curated components / read one's source first");
|
|
3792
|
+
}
|