@montytools/cli 0.5.1 → 0.5.3
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 +514 -11
- package/lib/schemaCodegen.mjs +18 -5
- package/lib/views.mjs +138 -0
- package/package.json +3 -2
- package/skills/monty-build/SKILL.md +32 -6
- package/skills/monty-operate/SKILL.md +19 -1
- package/template/AGENTS.md +30 -7
- package/template/package.json +1 -1
package/bin/monty.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { CATALOG, REGISTRIES } from "./catalog.mjs";
|
|
|
18
18
|
import { CompileError, 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
22
|
|
|
22
23
|
// MONTY_HOME overrides the state root (default ~/.monty): config.json,
|
|
23
24
|
// apps/, and desktop.json all live under it. This is how a second, isolated
|
|
@@ -675,6 +676,12 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
|
|
|
675
676
|
- Formulas are strings in the Monty expression grammar, e.g.
|
|
676
677
|
\`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
|
|
677
678
|
ABOVE the formula and \`metrics.<name>\` are in scope.
|
|
679
|
+
- A filtered or sorted slice of one table is a shared saved view. Use
|
|
680
|
+
\`monty views set <table> <name> ...\` before adding custom code. A custom
|
|
681
|
+
page is for interactions, multi-table layouts, or visualizations that a
|
|
682
|
+
system record page cannot express. If the page is still one table, start
|
|
683
|
+
with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
|
|
684
|
+
with the record controls from \`@montytools/sdk/ui\`.
|
|
678
685
|
- Need a bespoke page later? \`monty add page\` declares it and upgrades this
|
|
679
686
|
app with a SPA scaffold; \`monty save\` ships the code.
|
|
680
687
|
`;
|
|
@@ -984,7 +991,7 @@ async function freePort(start) {
|
|
|
984
991
|
// Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
|
|
985
992
|
// minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
|
|
986
993
|
// SDK's vite plugin) and upgrades the app automatically before dev/deploy.
|
|
987
|
-
const MIN_SDK = "0.2.
|
|
994
|
+
const MIN_SDK = "0.2.3";
|
|
988
995
|
const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
|
|
989
996
|
|
|
990
997
|
function installedSdkVersion(appDir) {
|
|
@@ -1312,7 +1319,7 @@ async function performTakeover(appDir, s) {
|
|
|
1312
1319
|
spawnSync("taskkill", ["/pid", String(s.pid), "/T", "/F"], { stdio: "ignore" });
|
|
1313
1320
|
if (Number.isInteger(s.vitePid)) spawnSync("taskkill", ["/pid", String(s.vitePid), "/T", "/F"], { stdio: "ignore" });
|
|
1314
1321
|
} else if (!signalPid(s.pid, "SIGTERM")) {
|
|
1315
|
-
fail("TAKEOVER_FAILED", `The running session (pid ${s.pid}) belongs to another user. Stop it
|
|
1322
|
+
fail("TAKEOVER_FAILED", `The running session (pid ${s.pid}) belongs to another user. Stop it from the terminal or Desktop instance that started it, then rerun \`monty dev\`. If that owner is unavailable, check \`monty support status\` and submit the incident when sharing is enabled.`);
|
|
1316
1323
|
}
|
|
1317
1324
|
let ok = await waitGone(TAKEOVER_WAIT_MS);
|
|
1318
1325
|
if (!ok && process.platform !== "win32") {
|
|
@@ -2874,7 +2881,7 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
|
|
|
2874
2881
|
// API. `app` is the plain slug — every app has ONE set of records.
|
|
2875
2882
|
// Results are ONE JSON document on stdout so agents can pipe.
|
|
2876
2883
|
|
|
2877
|
-
const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
|
|
2884
|
+
const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host", "name", "type", "out", "table", "record", "field"]);
|
|
2878
2885
|
|
|
2879
2886
|
// rest, minus flags AND their values — `monty data list leads --app crm`
|
|
2880
2887
|
// must not read "crm" as a positional. Boolean flags have no value and
|
|
@@ -2920,9 +2927,9 @@ function resolveDataApp() {
|
|
|
2920
2927
|
|
|
2921
2928
|
// mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
|
|
2922
2929
|
// in 5 minutes — minted fresh per invocation, never stored.
|
|
2923
|
-
async function
|
|
2930
|
+
async function workspaceAuth() {
|
|
2924
2931
|
const { host, key } = loadConfig();
|
|
2925
|
-
if (!key) fail("NOT_LOGGED_IN", `
|
|
2932
|
+
if (!key) fail("NOT_LOGGED_IN", `This command needs your workspace (${host}). Run \`monty login\` first.`);
|
|
2926
2933
|
let convexUrl = null;
|
|
2927
2934
|
try {
|
|
2928
2935
|
convexUrl = (await fetch(`${host}/api/config`).then((r) => r.json()))?.convexUrl;
|
|
@@ -2936,20 +2943,20 @@ async function dataAuth() {
|
|
|
2936
2943
|
if (!r.ok || !body?.token) {
|
|
2937
2944
|
fail(body?.code ?? `HTTP_${r.status}`, body?.fix ?? "Minting a workspace token failed. Run `monty login`, then retry.");
|
|
2938
2945
|
}
|
|
2939
|
-
return { convexUrl, token: body.token };
|
|
2946
|
+
return { host, convexUrl, token: body.token };
|
|
2940
2947
|
}
|
|
2941
2948
|
|
|
2942
2949
|
// One records function over Convex's public HTTP API (plain-JSON format —
|
|
2943
2950
|
// app data is JSON by construction, no convex encoding needed). ConvexError
|
|
2944
2951
|
// payloads carry { code, fix } and surface verbatim: errors stay
|
|
2945
2952
|
// instructions in the terminal the agent is watching.
|
|
2946
|
-
async function
|
|
2953
|
+
async function callConvex(kind, path, args, auth) {
|
|
2947
2954
|
let body = null;
|
|
2948
2955
|
try {
|
|
2949
2956
|
const r = await fetch(`${auth.convexUrl}/api/${kind}`, {
|
|
2950
2957
|
method: "POST",
|
|
2951
2958
|
headers: { "content-type": "application/json", authorization: `Bearer ${auth.token}` },
|
|
2952
|
-
body: JSON.stringify({ path
|
|
2959
|
+
body: JSON.stringify({ path, args, format: "json" }),
|
|
2953
2960
|
});
|
|
2954
2961
|
body = await r.json().catch(() => null);
|
|
2955
2962
|
} catch { /* handled below */ }
|
|
@@ -2962,6 +2969,10 @@ async function callRecords(kind, fn, args, auth) {
|
|
|
2962
2969
|
return body.value;
|
|
2963
2970
|
}
|
|
2964
2971
|
|
|
2972
|
+
async function callRecords(kind, fn, args, auth) {
|
|
2973
|
+
return callConvex(kind, `records:${fn}`, args, auth);
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2965
2976
|
function printJson(value) {
|
|
2966
2977
|
console.log(JSON.stringify(value, null, 2));
|
|
2967
2978
|
}
|
|
@@ -2990,10 +3001,57 @@ function dataUsage() {
|
|
|
2990
3001
|
console.log(" update <table> <id> --data '<json>' [--unset field,field]");
|
|
2991
3002
|
console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
|
|
2992
3003
|
console.log(" remove <table> <id>");
|
|
3004
|
+
console.log(" upload <path> [--name N] [--type mime] store a file (10MB cap); prints the descriptor to put in a `file` field");
|
|
3005
|
+
console.log(" [--table <t> --record <id> --field <f>] …and set that row's field in the same command");
|
|
3006
|
+
console.log(" download <file-id> [--out path] fetch a stored file's bytes (id from a row's file field)");
|
|
2993
3007
|
console.log("target: --app <slug> (or run inside the app folder)");
|
|
2994
3008
|
process.exit(1);
|
|
2995
3009
|
}
|
|
2996
3010
|
|
|
3011
|
+
// Uploads without --type get the MIME their extension implies; unknown
|
|
3012
|
+
// extensions stay application/octet-stream (the server stores, never sniffs).
|
|
3013
|
+
const MIME_BY_EXT = {
|
|
3014
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
3015
|
+
webp: "image/webp", svg: "image/svg+xml", ico: "image/x-icon",
|
|
3016
|
+
pdf: "application/pdf", json: "application/json", csv: "text/csv",
|
|
3017
|
+
txt: "text/plain", md: "text/markdown", html: "text/html",
|
|
3018
|
+
zip: "application/zip", mp3: "audio/mpeg", wav: "audio/wav",
|
|
3019
|
+
mp4: "video/mp4", webm: "video/webm",
|
|
3020
|
+
};
|
|
3021
|
+
|
|
3022
|
+
function inferContentType(name) {
|
|
3023
|
+
const ext = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
|
|
3024
|
+
return (ext && MIME_BY_EXT[ext]) || "application/octet-stream";
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
const DATA_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
3028
|
+
|
|
3029
|
+
// POST/GET on the host's /api/files — the same rail the SDK file helpers
|
|
3030
|
+
// ride, authorized with the invocation's 5-minute workspace token.
|
|
3031
|
+
async function callFiles(method, params, auth, app, opts = {}) {
|
|
3032
|
+
const url = new URL("/api/files", auth.host);
|
|
3033
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
3034
|
+
let r;
|
|
3035
|
+
try {
|
|
3036
|
+
r = await fetch(url, {
|
|
3037
|
+
method,
|
|
3038
|
+
headers: {
|
|
3039
|
+
authorization: `Bearer ${auth.token}`,
|
|
3040
|
+
"x-monty-app": app,
|
|
3041
|
+
...(opts.contentType ? { "content-type": opts.contentType } : {}),
|
|
3042
|
+
},
|
|
3043
|
+
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
3044
|
+
});
|
|
3045
|
+
} catch {
|
|
3046
|
+
fail("HOST_UNREACHABLE", `The file endpoint at ${auth.host} did not answer — check the network and retry.`);
|
|
3047
|
+
}
|
|
3048
|
+
if (!r.ok) {
|
|
3049
|
+
const err = await r.json().catch(() => null);
|
|
3050
|
+
fail(err?.code ?? `HTTP_${r.status}`, err?.fix ?? "The file request failed. Retry; if it persists, report it.");
|
|
3051
|
+
}
|
|
3052
|
+
return r;
|
|
3053
|
+
}
|
|
3054
|
+
|
|
2997
3055
|
async function data() {
|
|
2998
3056
|
const [verb, table, id] = dataPositionals();
|
|
2999
3057
|
|
|
@@ -3028,12 +3086,88 @@ async function data() {
|
|
|
3028
3086
|
return;
|
|
3029
3087
|
}
|
|
3030
3088
|
|
|
3089
|
+
if (verb === "upload") {
|
|
3090
|
+
// The positional is a file PATH, not a table — the descriptor this
|
|
3091
|
+
// prints is what a `file`-typed field stores; --table/--record/--field
|
|
3092
|
+
// set it on an existing row in the same command.
|
|
3093
|
+
const path = table;
|
|
3094
|
+
if (!path) fail("MISSING_PATH", "Usage: monty data upload <path> [--name N] [--type mime] [--table <t> --record <id> --field <f>].");
|
|
3095
|
+
const attachTable = flag("table");
|
|
3096
|
+
const attachRecord = flag("record");
|
|
3097
|
+
const attachField = flag("field");
|
|
3098
|
+
const attachFlags = [attachTable, attachRecord, attachField].filter((f) => f !== undefined);
|
|
3099
|
+
if (attachFlags.length > 0 && attachFlags.length < 3) {
|
|
3100
|
+
fail("BAD_ATTACH", "Attaching needs all three of --table, --record, and --field — or none (then put the printed descriptor in a file field yourself).");
|
|
3101
|
+
}
|
|
3102
|
+
let bytes;
|
|
3103
|
+
try {
|
|
3104
|
+
bytes = readFileSync(path);
|
|
3105
|
+
} catch {
|
|
3106
|
+
fail("NO_SUCH_FILE", `Could not read "${path}" — check the path.`);
|
|
3107
|
+
}
|
|
3108
|
+
if (bytes.byteLength === 0) fail("EMPTY_FILE", `"${path}" is empty — nothing to upload.`);
|
|
3109
|
+
if (bytes.byteLength > DATA_MAX_FILE_BYTES) {
|
|
3110
|
+
fail("FILE_TOO_LARGE", `Files are limited to ${DATA_MAX_FILE_BYTES} bytes; "${path}" is ${bytes.byteLength}. Compress it first.`);
|
|
3111
|
+
}
|
|
3112
|
+
const name = flag("name") ?? basename(path);
|
|
3113
|
+
const contentType = flag("type") ?? inferContentType(name);
|
|
3114
|
+
const app = resolveDataApp();
|
|
3115
|
+
const auth = await workspaceAuth();
|
|
3116
|
+
if (attachFlags.length === 3) {
|
|
3117
|
+
// Prove the target row exists BEFORE storing bytes — a failed attach
|
|
3118
|
+
// after the upload would orphan the file.
|
|
3119
|
+
const row = await callRecords("query", "get", { app, table: attachTable, id: attachRecord }, auth);
|
|
3120
|
+
if (!row) fail("NOT_FOUND", `No record "${attachRecord}" in table "${attachTable}" — ids come from \`monty data list ${attachTable}\`.`);
|
|
3121
|
+
}
|
|
3122
|
+
const res = await callFiles("POST", { name }, auth, app, { body: bytes, contentType });
|
|
3123
|
+
const file = await res.json();
|
|
3124
|
+
if (attachFlags.length === 3) {
|
|
3125
|
+
await callRecords("mutation", "update", {
|
|
3126
|
+
app,
|
|
3127
|
+
table: attachTable,
|
|
3128
|
+
id: attachRecord,
|
|
3129
|
+
data: { [attachField]: file },
|
|
3130
|
+
}, auth);
|
|
3131
|
+
printJson({ ok: true, id: attachRecord, field: attachField, file });
|
|
3132
|
+
} else {
|
|
3133
|
+
printJson({ file });
|
|
3134
|
+
}
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
if (verb === "download") {
|
|
3139
|
+
// The positional is the file id (a row's file-field descriptor carries
|
|
3140
|
+
// it), not a table.
|
|
3141
|
+
const fileId = table;
|
|
3142
|
+
if (!fileId) fail("MISSING_ID", "Usage: monty data download <file-id> [--out path] — ids come from a row's file field (its `id` key).");
|
|
3143
|
+
const app = resolveDataApp();
|
|
3144
|
+
const auth = await workspaceAuth();
|
|
3145
|
+
const res = await callFiles("GET", { id: fileId }, auth, app);
|
|
3146
|
+
const disposition = res.headers.get("content-disposition") ?? "";
|
|
3147
|
+
const remoteName = /filename="([^"]+)"/.exec(disposition)?.[1] ?? fileId;
|
|
3148
|
+
const out = flag("out") ?? remoteName;
|
|
3149
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
3150
|
+
try {
|
|
3151
|
+
writeFileSync(out, body);
|
|
3152
|
+
} catch {
|
|
3153
|
+
fail("WRITE_FAILED", `Could not write to "${out}" — check the path and permissions.`);
|
|
3154
|
+
}
|
|
3155
|
+
printJson({
|
|
3156
|
+
ok: true,
|
|
3157
|
+
path: out,
|
|
3158
|
+
name: remoteName,
|
|
3159
|
+
contentType: res.headers.get("content-type") ?? "application/octet-stream",
|
|
3160
|
+
size: body.byteLength,
|
|
3161
|
+
});
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3031
3165
|
const VERBS = new Set(["list", "get", "insert", "update", "upsert", "remove"]);
|
|
3032
3166
|
if (!verb || !VERBS.has(verb)) dataUsage();
|
|
3033
3167
|
if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
|
|
3034
3168
|
|
|
3035
3169
|
const app = resolveDataApp();
|
|
3036
|
-
const auth = await
|
|
3170
|
+
const auth = await workspaceAuth();
|
|
3037
3171
|
|
|
3038
3172
|
switch (verb) {
|
|
3039
3173
|
case "list": {
|
|
@@ -3127,12 +3261,373 @@ async function data() {
|
|
|
3127
3261
|
}
|
|
3128
3262
|
}
|
|
3129
3263
|
|
|
3264
|
+
// ── monty views ────────────────────────────────────────────────────────────
|
|
3265
|
+
// Shared saved views are shell state, not app code. Agents manage them
|
|
3266
|
+
// through the same workspace-scoped platform functions the record page uses.
|
|
3267
|
+
// The CLI accepts concise filters, then stores the shell's existing
|
|
3268
|
+
// { filters, sort, hidden } ViewConfig shape unchanged.
|
|
3269
|
+
|
|
3270
|
+
const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name"]);
|
|
3271
|
+
|
|
3272
|
+
function viewPositionals() {
|
|
3273
|
+
const out = [];
|
|
3274
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3275
|
+
const arg = rest[i];
|
|
3276
|
+
if (arg.startsWith("--")) {
|
|
3277
|
+
if (VIEW_VALUE_FLAGS.has(arg.slice(2))) i++;
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
3280
|
+
out.push(arg);
|
|
3281
|
+
}
|
|
3282
|
+
return out;
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
function viewsUsage() {
|
|
3286
|
+
console.log("usage: monty views <list|set|update|remove> <table> [name] [flags]");
|
|
3287
|
+
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]");
|
|
3290
|
+
console.log(" remove <table> <name> [--app slug]");
|
|
3291
|
+
console.log("filter values: null = empty; scalar = exact; array = any listed value; {\"contains\":\"text\"}; {\"min\":0,\"max\":100}");
|
|
3292
|
+
console.log("example: monty views set leads \"Evaluate\" --filter '{\"pipelineId\":null}'");
|
|
3293
|
+
process.exit(1);
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
function validateViewFlagValues() {
|
|
3297
|
+
for (const name of VIEW_VALUE_FLAGS) {
|
|
3298
|
+
const index = rest.indexOf(`--${name}`);
|
|
3299
|
+
if (index >= 0 && (rest[index + 1] === undefined || rest[index + 1].startsWith("--"))) {
|
|
3300
|
+
fail("MISSING_FLAG_VALUE", `--${name} needs a value.`);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
function viewName(input) {
|
|
3306
|
+
const name = input?.trim() ?? "";
|
|
3307
|
+
if (name === "" || name.length > 60) {
|
|
3308
|
+
fail("BAD_VIEW_NAME", "A saved view name must be 1–60 characters.");
|
|
3309
|
+
}
|
|
3310
|
+
return name;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
async function viewContext(table) {
|
|
3314
|
+
const app = resolveDataApp();
|
|
3315
|
+
const auth = await workspaceAuth();
|
|
3316
|
+
const manifest = await callConvex("query", "platform:appManifest", { slug: app }, auth);
|
|
3317
|
+
if (!manifest) {
|
|
3318
|
+
fail("NO_MANIFEST", `App "${app}" has no stored manifest, so it has no system table views.`);
|
|
3319
|
+
}
|
|
3320
|
+
const tableSpec = manifest.tables?.[table];
|
|
3321
|
+
if (!tableSpec) {
|
|
3322
|
+
fail("NO_SUCH_TABLE", `App "${app}" has no table "${table}". Tables: ${Object.keys(manifest.tables ?? {}).join(", ") || "(none)"}.`);
|
|
3323
|
+
}
|
|
3324
|
+
const declaredPages = Object.values(manifest.pages ?? {});
|
|
3325
|
+
const hasTableView =
|
|
3326
|
+
declaredPages.length === 0 ||
|
|
3327
|
+
declaredPages.some((page) => page?.kind === "view" && page.table === table);
|
|
3328
|
+
if (!hasTableView) {
|
|
3329
|
+
fail("NO_TABLE_VIEW", `Table "${table}" is not shown by a system record page. Add a { kind: "view", table: "${table}" } page through \`monty schema\`, then save the view.`);
|
|
3330
|
+
}
|
|
3331
|
+
return {
|
|
3332
|
+
app,
|
|
3333
|
+
auth,
|
|
3334
|
+
fields: Object.keys(tableSpec.fields ?? {}),
|
|
3335
|
+
};
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
function requestedViewConfig(fields) {
|
|
3339
|
+
let config;
|
|
3340
|
+
try {
|
|
3341
|
+
config = {
|
|
3342
|
+
filters: normalizeViewFilters(parseJsonFlag("filter")),
|
|
3343
|
+
sort: parseViewSort(flag("sort")),
|
|
3344
|
+
hidden: parseHiddenColumns(flag("hide")),
|
|
3345
|
+
};
|
|
3346
|
+
return validateViewColumns(config, fields);
|
|
3347
|
+
} catch (error) {
|
|
3348
|
+
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
function requestedViewPatch() {
|
|
3353
|
+
try {
|
|
3354
|
+
return {
|
|
3355
|
+
...(rest.includes("--filter") ? { filters: normalizeViewFilters(parseJsonFlag("filter")) } : {}),
|
|
3356
|
+
...(rest.includes("--sort") ? { sort: parseViewSort(flag("sort")) } : {}),
|
|
3357
|
+
...(rest.includes("--hide") ? { hidden: parseHiddenColumns(flag("hide")) } : {}),
|
|
3358
|
+
};
|
|
3359
|
+
} catch (error) {
|
|
3360
|
+
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
function checkedViewConfig(config, fields) {
|
|
3365
|
+
try {
|
|
3366
|
+
return validateViewColumns(config, fields);
|
|
3367
|
+
} catch (error) {
|
|
3368
|
+
fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
async function views() {
|
|
3373
|
+
validateViewFlagValues();
|
|
3374
|
+
const [verb, table, rawName] = viewPositionals();
|
|
3375
|
+
if (!verb || !table || rest.includes("--help") || rest.includes("-h")) viewsUsage();
|
|
3376
|
+
if (!["list", "set", "update", "remove"].includes(verb)) viewsUsage();
|
|
3377
|
+
const { app, auth, fields } = await viewContext(table);
|
|
3378
|
+
|
|
3379
|
+
if (verb === "list") {
|
|
3380
|
+
const saved = await callConvex("query", "platform:savedViews", { app, page: table }, auth);
|
|
3381
|
+
printJson({ app, table, views: saved });
|
|
3382
|
+
return;
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
const name = viewName(rawName);
|
|
3386
|
+
const saved = await callConvex("query", "platform:savedViews", { app, page: table }, auth);
|
|
3387
|
+
const existing = saved.find((view) => view.name === name);
|
|
3388
|
+
|
|
3389
|
+
if (verb === "remove") {
|
|
3390
|
+
if (!existing) {
|
|
3391
|
+
fail("VIEW_NOT_FOUND", `No saved view named "${name}" exists for ${table}. Run \`monty views list ${table}\` to see the names.`);
|
|
3392
|
+
}
|
|
3393
|
+
await callConvex("mutation", "platform:deleteView", { app, page: table, name }, auth);
|
|
3394
|
+
printJson({ ok: true, app, table, name, removed: true });
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
if (verb === "update") {
|
|
3399
|
+
if (!existing) {
|
|
3400
|
+
fail("VIEW_NOT_FOUND", `No saved view named "${name}" exists for ${table}. Run \`monty views list ${table}\` to see the names.`);
|
|
3401
|
+
}
|
|
3402
|
+
const nextName = flag("name") === undefined ? name : viewName(flag("name"));
|
|
3403
|
+
if (nextName !== name && saved.some((view) => view.name === nextName)) {
|
|
3404
|
+
fail("VIEW_ALREADY_EXISTS", `A saved view named "${nextName}" already exists for ${table}. Choose another name or update that view directly.`);
|
|
3405
|
+
}
|
|
3406
|
+
const patch = requestedViewPatch();
|
|
3407
|
+
if (nextName === name && Object.keys(patch).length === 0) {
|
|
3408
|
+
fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, or --hide. Omitted properties stay unchanged.");
|
|
3409
|
+
}
|
|
3410
|
+
const config = checkedViewConfig(mergeViewConfig(existing.config, patch), fields);
|
|
3411
|
+
await callConvex("mutation", "platform:saveView", { app, page: table, name: nextName, config }, auth);
|
|
3412
|
+
if (nextName !== name) {
|
|
3413
|
+
await callConvex("mutation", "platform:deleteView", { app, page: table, name }, auth);
|
|
3414
|
+
}
|
|
3415
|
+
printJson({
|
|
3416
|
+
ok: true,
|
|
3417
|
+
app,
|
|
3418
|
+
table,
|
|
3419
|
+
name: nextName,
|
|
3420
|
+
updated: true,
|
|
3421
|
+
renamed: nextName !== name,
|
|
3422
|
+
...(nextName !== name ? { previousName: name } : {}),
|
|
3423
|
+
config,
|
|
3424
|
+
});
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
const config = requestedViewConfig(fields);
|
|
3429
|
+
await callConvex("mutation", "platform:saveView", { app, page: table, name, config }, auth);
|
|
3430
|
+
printJson({ ok: true, app, table, name, created: !existing, config });
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
// ── monty support ─────────────────────────────────────────────────────────
|
|
3434
|
+
// Workspace admins opt in once. After that, agents may send one structured
|
|
3435
|
+
// report for a persistent platform failure or missing platform capability.
|
|
3436
|
+
// The CLI adds selected local diagnostics, but never reads source files,
|
|
3437
|
+
// environment variables, app records, or a raw conversation transcript.
|
|
3438
|
+
function supportUsage() {
|
|
3439
|
+
console.log("usage: monty support <status|enable|disable|submit>");
|
|
3440
|
+
console.log(" status show whether agent support reports are allowed");
|
|
3441
|
+
console.log(" enable [--yes] allow agents to send bounded reports to Monty");
|
|
3442
|
+
console.log(" disable stop all future reports immediately");
|
|
3443
|
+
console.log(" submit --data '<json|->' send an incident, unexpected behavior, or missing feature");
|
|
3444
|
+
console.log('report JSON: {"kind":"incident|unexpected_behavior|missing_feature","title":"…","summary":"…","problem":"…","expected":"…","attempted":["…"],"error":"…"}');
|
|
3445
|
+
process.exit(1);
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
function redactSupportText(value) {
|
|
3449
|
+
return value
|
|
3450
|
+
.replaceAll(homedir(), "~")
|
|
3451
|
+
.replace(/mk_[0-9a-f]{48}/gi, "[REDACTED_CLI_KEY]")
|
|
3452
|
+
.replace(/\bBearer\s+[^\s"']+/gi, "Bearer [REDACTED]")
|
|
3453
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED_JWT]")
|
|
3454
|
+
.replace(/([?&](?:ticket|token|key)=)[^&\s]+/gi, "$1[REDACTED]")
|
|
3455
|
+
.replace(/\b([A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY))=\S+/g, "$1=[REDACTED]");
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
async function supportDiagnostics(appDir, host, includeLogs) {
|
|
3459
|
+
let session;
|
|
3460
|
+
let errorLog;
|
|
3461
|
+
if (appDir) {
|
|
3462
|
+
const probe = await checkDevSession(appDir);
|
|
3463
|
+
const recorded = probe.session;
|
|
3464
|
+
if (recorded) {
|
|
3465
|
+
session = JSON.stringify({
|
|
3466
|
+
live: probe.live,
|
|
3467
|
+
...(probe.reason ? { reason: probe.reason } : {}),
|
|
3468
|
+
state: recorded.state ?? null,
|
|
3469
|
+
cli: recorded.cli ?? null,
|
|
3470
|
+
pid: Number.isInteger(recorded.pid) ? recorded.pid : null,
|
|
3471
|
+
port: Number.isInteger(recorded.port) ? recorded.port : null,
|
|
3472
|
+
sessionId: typeof recorded.sessionId === "string" ? recorded.sessionId : null,
|
|
3473
|
+
startedAt: recorded.startedAt ?? null,
|
|
3474
|
+
updatedAt: recorded.updatedAt ?? null,
|
|
3475
|
+
loggedIn: recorded.loggedIn ?? null,
|
|
3476
|
+
lastHeartbeatAt: recorded.lastHeartbeatAt ?? null,
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
if (includeLogs) {
|
|
3480
|
+
const diagnosticLines = readLogTail(appDir, 240)
|
|
3481
|
+
.filter((line) => /\b(error|warn|failed|failure|takeover|superseded|drift)\b|\[MontyError\]/i.test(line))
|
|
3482
|
+
.slice(-80);
|
|
3483
|
+
if (diagnosticLines.length) {
|
|
3484
|
+
errorLog = redactSupportText(diagnosticLines.join("\n")).slice(-12_000);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
return {
|
|
3489
|
+
cliVersion: CLI_VERSION,
|
|
3490
|
+
nodeVersion: process.version,
|
|
3491
|
+
platform: process.platform,
|
|
3492
|
+
arch: process.arch,
|
|
3493
|
+
host,
|
|
3494
|
+
...(session ? { session: redactSupportText(session) } : {}),
|
|
3495
|
+
...(errorLog ? { errorLog } : {}),
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
async function supportRequest(method, payload) {
|
|
3500
|
+
const { host, key } = loadConfig();
|
|
3501
|
+
if (!key) {
|
|
3502
|
+
fail("NOT_LOGGED_IN", `Support settings and reports need your workspace (${host}). Run \`monty login\` first.`);
|
|
3503
|
+
}
|
|
3504
|
+
let response;
|
|
3505
|
+
try {
|
|
3506
|
+
response = await fetch(`${host}/api/support`, {
|
|
3507
|
+
method,
|
|
3508
|
+
headers: {
|
|
3509
|
+
authorization: `Bearer ${key}`,
|
|
3510
|
+
...(payload === undefined ? {} : { "content-type": "application/json" }),
|
|
3511
|
+
},
|
|
3512
|
+
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
|
3513
|
+
signal: AbortSignal.timeout(15_000),
|
|
3514
|
+
});
|
|
3515
|
+
} catch {
|
|
3516
|
+
fail("SUPPORT_UNREACHABLE", `Could not reach ${host}/api/support. Check the network and retry.`);
|
|
3517
|
+
}
|
|
3518
|
+
const body = await response.json().catch(() => null);
|
|
3519
|
+
if (!response.ok || !body?.ok) {
|
|
3520
|
+
fail(body?.code ?? `HTTP_${response.status}`, body?.fix ?? "The support request failed. Retry once.");
|
|
3521
|
+
}
|
|
3522
|
+
return { host, body };
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
async function support() {
|
|
3526
|
+
const verb = rest[0];
|
|
3527
|
+
if (!verb || rest.includes("--help") || rest.includes("-h")) supportUsage();
|
|
3528
|
+
|
|
3529
|
+
if (verb === "status") {
|
|
3530
|
+
const { body } = await supportRequest("GET");
|
|
3531
|
+
console.log(`support sharing: ${body.enabled ? "enabled" : "disabled"}`);
|
|
3532
|
+
if (body.enabledAt) console.log(`enabled at: ${new Date(body.enabledAt).toISOString()}`);
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
if (verb === "enable") {
|
|
3537
|
+
console.log("Monty support sharing lets coding agents report persistent Monty platform errors and missing platform features.");
|
|
3538
|
+
console.log("Reports include: an agent-written summary, tool identity, CLI and session metadata, and selected recent error or warning lines with known secrets redacted.");
|
|
3539
|
+
console.log("Error lines may contain names or values that a tool printed. Agents can omit them per report with includeLogs: false.");
|
|
3540
|
+
console.log("The command does not read source files, environment variables, tool records, or the full conversation.");
|
|
3541
|
+
let approved = rest.includes("--yes");
|
|
3542
|
+
if (!approved && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3543
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3544
|
+
approved = /^y(?:es)?$/i.test((await rl.question("Allow these reports for this workspace? [y/N] ")).trim());
|
|
3545
|
+
rl.close();
|
|
3546
|
+
}
|
|
3547
|
+
if (!approved) {
|
|
3548
|
+
fail("SUPPORT_CONSENT_REQUIRED", "A person must approve support sharing. Review the notice above, ask the workspace admin, then rerun `monty support enable --yes` only after they agree.");
|
|
3549
|
+
}
|
|
3550
|
+
await supportRequest("PUT", { enabled: true });
|
|
3551
|
+
console.log("support sharing: enabled");
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
if (verb === "disable") {
|
|
3556
|
+
await supportRequest("PUT", { enabled: false });
|
|
3557
|
+
console.log("support sharing: disabled");
|
|
3558
|
+
return;
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
if (verb !== "submit") supportUsage();
|
|
3562
|
+
|
|
3563
|
+
const rawData = flag("data");
|
|
3564
|
+
if (rawData === undefined) {
|
|
3565
|
+
fail("SUPPORT_REPORT_REQUIRED", "Pass the agent summary with `monty support submit --data '<json>'`. Use `--data -` to read JSON from stdin.");
|
|
3566
|
+
}
|
|
3567
|
+
let input;
|
|
3568
|
+
try {
|
|
3569
|
+
const raw = rawData === "-" ? readFileSync(0, "utf8") : rawData;
|
|
3570
|
+
input = JSON.parse(raw);
|
|
3571
|
+
} catch {
|
|
3572
|
+
fail("BAD_SUPPORT_REPORT", "The support report is not valid JSON. Pass one object with kind, title, summary, problem, expected, and optional attempted/error fields.");
|
|
3573
|
+
}
|
|
3574
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
3575
|
+
fail("BAD_SUPPORT_REPORT", "The support report must be one JSON object.");
|
|
3576
|
+
}
|
|
3577
|
+
const kinds = new Set(["incident", "unexpected_behavior", "missing_feature"]);
|
|
3578
|
+
if (!kinds.has(input.kind)) {
|
|
3579
|
+
fail("BAD_SUPPORT_REPORT", "kind must be `incident`, `unexpected_behavior`, or `missing_feature`.");
|
|
3580
|
+
}
|
|
3581
|
+
for (const field of ["title", "summary", "problem", "expected"]) {
|
|
3582
|
+
if (typeof input[field] !== "string" || !input[field].trim()) {
|
|
3583
|
+
fail("BAD_SUPPORT_REPORT", `${field} must be a non-empty string.`);
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
if (input.attempted !== undefined && (!Array.isArray(input.attempted) || input.attempted.some((item) => typeof item !== "string"))) {
|
|
3587
|
+
fail("BAD_SUPPORT_REPORT", "attempted must be an array of short strings.");
|
|
3588
|
+
}
|
|
3589
|
+
if (input.error !== undefined && typeof input.error !== "string") {
|
|
3590
|
+
fail("BAD_SUPPORT_REPORT", "error must be a string when provided.");
|
|
3591
|
+
}
|
|
3592
|
+
if (input.includeLogs !== undefined && typeof input.includeLogs !== "boolean") {
|
|
3593
|
+
fail("BAD_SUPPORT_REPORT", "includeLogs must be true or false when provided.");
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
const appDir = findAppRoot(process.cwd());
|
|
3597
|
+
const { host } = loadConfig();
|
|
3598
|
+
const appSlug = typeof input.appSlug === "string"
|
|
3599
|
+
? input.appSlug
|
|
3600
|
+
: appDir
|
|
3601
|
+
? readSlugFromConfig(appDir)
|
|
3602
|
+
: undefined;
|
|
3603
|
+
const report = {
|
|
3604
|
+
...(appSlug ? { appSlug } : {}),
|
|
3605
|
+
kind: input.kind,
|
|
3606
|
+
title: redactSupportText(input.title.trim()),
|
|
3607
|
+
threadSummary: redactSupportText(input.summary.trim()),
|
|
3608
|
+
problem: redactSupportText(input.problem.trim()),
|
|
3609
|
+
expected: redactSupportText(input.expected.trim()),
|
|
3610
|
+
attempted: (input.attempted ?? [])
|
|
3611
|
+
.map((item) => redactSupportText(item.trim()))
|
|
3612
|
+
.filter(Boolean),
|
|
3613
|
+
...(input.error?.trim() ? { error: redactSupportText(input.error.trim()) } : {}),
|
|
3614
|
+
diagnostics: await supportDiagnostics(
|
|
3615
|
+
appDir,
|
|
3616
|
+
host,
|
|
3617
|
+
input.includeLogs ?? input.kind !== "missing_feature",
|
|
3618
|
+
),
|
|
3619
|
+
};
|
|
3620
|
+
const { body } = await supportRequest("POST", report);
|
|
3621
|
+
console.log(`support ticket: ${body.ticketId}`);
|
|
3622
|
+
console.log("support: submitted to Monty");
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3130
3625
|
// ── dispatch ───────────────────────────────────────────────────────────────
|
|
3131
3626
|
// Keep agent skills fresh on every invocation (user level + current app).
|
|
3132
3627
|
// `dev` and `logs` skip the refresh here: attach and log reads must stay
|
|
3133
3628
|
// fast (a skills refresh can shell out to npx for two minutes) — dev's
|
|
3134
3629
|
// fresh-start path installs skills itself once it owns the session.
|
|
3135
|
-
if (command !== "dev" && command !== "logs") {
|
|
3630
|
+
if (command !== "dev" && command !== "logs" && command !== "support") {
|
|
3136
3631
|
installSkills({ appDir: findAppRoot(process.cwd()) });
|
|
3137
3632
|
}
|
|
3138
3633
|
|
|
@@ -3296,11 +3791,17 @@ switch (command) {
|
|
|
3296
3791
|
case "schema":
|
|
3297
3792
|
await schemaCmd();
|
|
3298
3793
|
break;
|
|
3794
|
+
case "views":
|
|
3795
|
+
await views();
|
|
3796
|
+
break;
|
|
3797
|
+
case "support":
|
|
3798
|
+
await support();
|
|
3799
|
+
break;
|
|
3299
3800
|
case "secret":
|
|
3300
3801
|
await secret();
|
|
3301
3802
|
break;
|
|
3302
3803
|
default:
|
|
3303
|
-
console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|skills>");
|
|
3804
|
+
console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|schema|views|support|skills>");
|
|
3304
3805
|
console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
|
|
3305
3806
|
console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
|
|
3306
3807
|
console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
|
|
@@ -3320,6 +3821,8 @@ switch (command) {
|
|
|
3320
3821
|
console.log(" deploy alias of save");
|
|
3321
3822
|
console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
|
|
3322
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");
|
|
3323
3826
|
console.log(" skills install/refresh the agent build skill");
|
|
3324
3827
|
process.exit(command ? 1 : 0);
|
|
3325
3828
|
}
|
package/lib/schemaCodegen.mjs
CHANGED
|
@@ -32,7 +32,8 @@ export function manifestHash(manifest) {
|
|
|
32
32
|
|
|
33
33
|
const IMPORTABLE = [
|
|
34
34
|
"defineApp", "formula", "lookup", "montyDate", "montyFileSchema",
|
|
35
|
-
"montyMember", "montyMoney", "
|
|
35
|
+
"montyMember", "montyMoney", "montyMultiSelect", "montyPercent",
|
|
36
|
+
"montyPhone", "montyRating", "montyRef", "montySelect", "rollup", "self",
|
|
36
37
|
];
|
|
37
38
|
|
|
38
39
|
function fieldsInOrder(section) {
|
|
@@ -56,10 +57,20 @@ function storedSource(spec, used) {
|
|
|
56
57
|
src = `montyRef(${JSON.stringify(spec.table)})`;
|
|
57
58
|
break;
|
|
58
59
|
case "enum":
|
|
59
|
-
|
|
60
|
+
if (spec.valueDescriptions) {
|
|
61
|
+
used.add("montySelect");
|
|
62
|
+
src = `montySelect(${JSON.stringify(spec.values)}, ${JSON.stringify(spec.valueDescriptions)})`;
|
|
63
|
+
} else {
|
|
64
|
+
src = `z.enum(${JSON.stringify(spec.values)})`;
|
|
65
|
+
}
|
|
60
66
|
break;
|
|
61
67
|
case "multiSelect":
|
|
62
|
-
|
|
68
|
+
if (spec.valueDescriptions) {
|
|
69
|
+
used.add("montyMultiSelect");
|
|
70
|
+
src = `montyMultiSelect(${JSON.stringify(spec.values)}, ${JSON.stringify(spec.valueDescriptions)})`;
|
|
71
|
+
} else {
|
|
72
|
+
src = `z.array(z.enum(${JSON.stringify(spec.values)}))`;
|
|
73
|
+
}
|
|
63
74
|
break;
|
|
64
75
|
case "email":
|
|
65
76
|
src = "z.email()";
|
|
@@ -81,6 +92,7 @@ function storedSource(spec, used) {
|
|
|
81
92
|
src = "z.any()";
|
|
82
93
|
}
|
|
83
94
|
if (spec.optional) src += ".optional()";
|
|
95
|
+
if (spec.description) src += `.describe(${JSON.stringify(spec.description)})`;
|
|
84
96
|
return src;
|
|
85
97
|
}
|
|
86
98
|
|
|
@@ -115,6 +127,7 @@ function rollupSource(spec, used, indent) {
|
|
|
115
127
|
else lines.push("count: true,");
|
|
116
128
|
if (spec.over !== undefined) lines.push(`over: ${JSON.stringify(spec.over)},`);
|
|
117
129
|
if (spec.range !== undefined) lines.push(`range: ${JSON.stringify(spec.range)},`);
|
|
130
|
+
if (spec.description !== undefined) lines.push(`description: ${JSON.stringify(spec.description)},`);
|
|
118
131
|
const body = lines.map((l) => `${pad} ${l}`).join("\n");
|
|
119
132
|
return `rollup(${outputSource(spec.output, used)}, {\n${body}\n${pad}})`;
|
|
120
133
|
}
|
|
@@ -125,10 +138,10 @@ function fieldSource(spec, used, indent) {
|
|
|
125
138
|
return storedSource(spec, used);
|
|
126
139
|
case "formula":
|
|
127
140
|
used.add("formula");
|
|
128
|
-
return `formula(${outputSource(spec.output, used)}, ${JSON.stringify(spec.expr)})`;
|
|
141
|
+
return `formula(${outputSource(spec.output, used)}, ${JSON.stringify(spec.expr)}${spec.description !== undefined ? `, ${JSON.stringify(spec.description)}` : ""})`;
|
|
129
142
|
case "lookup":
|
|
130
143
|
used.add("lookup");
|
|
131
|
-
return `lookup(${outputSource(spec.output, used)}, { ref: ${JSON.stringify(spec.ref)}, field: ${JSON.stringify(spec.field)} })`;
|
|
144
|
+
return `lookup(${outputSource(spec.output, used)}, { ref: ${JSON.stringify(spec.ref)}, field: ${JSON.stringify(spec.field)}${spec.description !== undefined ? `, description: ${JSON.stringify(spec.description)}` : ""} })`;
|
|
132
145
|
case "rollup":
|
|
133
146
|
return rollupSource(spec, used, indent);
|
|
134
147
|
default:
|
package/lib/views.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
function badViewConfig(fix, code = "BAD_VIEW_CONFIG") {
|
|
2
|
+
const error = new Error(fix);
|
|
3
|
+
error.code = code;
|
|
4
|
+
error.fix = fix;
|
|
5
|
+
throw error;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isObject(value) {
|
|
9
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function valueToken(value, field) {
|
|
13
|
+
if (value === null || value === "") return "(empty)";
|
|
14
|
+
if (["string", "number", "boolean"].includes(typeof value)) return String(value);
|
|
15
|
+
badViewConfig(
|
|
16
|
+
`Filter "${field}" must be null, a string, number, boolean, an array of those values, { contains }, { min/max }, or a full saved-view filter.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function valuesFilter(values, field) {
|
|
21
|
+
if (values.length === 0) {
|
|
22
|
+
badViewConfig(`Filter "${field}" has no values. Remove it or provide at least one value.`);
|
|
23
|
+
}
|
|
24
|
+
return { kind: "values", values: [...new Set(values.map((value) => valueToken(value, field)))] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rangeFilter(input, field) {
|
|
28
|
+
const min = input.min;
|
|
29
|
+
const max = input.max;
|
|
30
|
+
if (min === undefined && max === undefined) {
|
|
31
|
+
badViewConfig(`Range filter "${field}" needs min, max, or both.`);
|
|
32
|
+
}
|
|
33
|
+
if (min !== undefined && (typeof min !== "number" || !Number.isFinite(min))) {
|
|
34
|
+
badViewConfig(`Range filter "${field}" has a non-numeric min.`);
|
|
35
|
+
}
|
|
36
|
+
if (max !== undefined && (typeof max !== "number" || !Number.isFinite(max))) {
|
|
37
|
+
badViewConfig(`Range filter "${field}" has a non-numeric max.`);
|
|
38
|
+
}
|
|
39
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
40
|
+
badViewConfig(`Range filter "${field}" has min greater than max.`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "range",
|
|
44
|
+
...(min !== undefined ? { min } : {}),
|
|
45
|
+
...(max !== undefined ? { max } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeFilter(input, field) {
|
|
50
|
+
if (Array.isArray(input)) return valuesFilter(input, field);
|
|
51
|
+
if (!isObject(input)) return valuesFilter([input], field);
|
|
52
|
+
|
|
53
|
+
if (input.kind === "values") {
|
|
54
|
+
if (!Array.isArray(input.values)) {
|
|
55
|
+
badViewConfig(`Values filter "${field}" needs a values array.`);
|
|
56
|
+
}
|
|
57
|
+
return valuesFilter(input.values, field);
|
|
58
|
+
}
|
|
59
|
+
if (input.kind === "text" || (input.kind === undefined && "contains" in input)) {
|
|
60
|
+
if (typeof input.contains !== "string" || input.contains === "") {
|
|
61
|
+
badViewConfig(`Text filter "${field}" needs a non-empty contains string.`);
|
|
62
|
+
}
|
|
63
|
+
return { kind: "text", contains: input.contains };
|
|
64
|
+
}
|
|
65
|
+
if (input.kind === "range" || (input.kind === undefined && ("min" in input || "max" in input))) {
|
|
66
|
+
return rangeFilter(input, field);
|
|
67
|
+
}
|
|
68
|
+
badViewConfig(
|
|
69
|
+
`Filter "${field}" is not recognized. Use a value, an array, { contains }, { min/max }, or a full saved-view filter.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Agent-facing filters stay concise while the stored value is exactly the
|
|
75
|
+
* shell's existing ViewConfig shape.
|
|
76
|
+
*
|
|
77
|
+
* { pipelineId: null } -> empty values filter
|
|
78
|
+
* { status: ["Evaluate", "New"] } -> values filter
|
|
79
|
+
* { title: { contains: "Director" } } -> text filter
|
|
80
|
+
* { amount: { min: 100 } } -> range filter
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeViewFilters(input) {
|
|
83
|
+
if (input === undefined) return {};
|
|
84
|
+
if (!isObject(input)) {
|
|
85
|
+
badViewConfig("--filter must be one JSON object keyed by field name.");
|
|
86
|
+
}
|
|
87
|
+
return Object.fromEntries(
|
|
88
|
+
Object.entries(input).map(([field, filter]) => {
|
|
89
|
+
if (field.trim() === "") badViewConfig("View filter field names cannot be empty.");
|
|
90
|
+
return [field, normalizeFilter(filter, field)];
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseViewSort(input) {
|
|
96
|
+
if (input === undefined) return null;
|
|
97
|
+
if (input === "none") return null;
|
|
98
|
+
const match = /^([^:]+):(asc|desc)$/.exec(input);
|
|
99
|
+
if (!match) {
|
|
100
|
+
badViewConfig('--sort must be shaped like "field:asc" or "field:desc", or be "none" to clear it.');
|
|
101
|
+
}
|
|
102
|
+
return { column: match[1], dir: match[2] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseHiddenColumns(input) {
|
|
106
|
+
if (input === undefined || input.trim() === "") return [];
|
|
107
|
+
return [...new Set(input.split(",").map((field) => field.trim()).filter(Boolean))];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function mergeViewConfig(existing, patch) {
|
|
111
|
+
const base = isObject(existing) ? existing : {};
|
|
112
|
+
return {
|
|
113
|
+
filters: Object.hasOwn(patch, "filters")
|
|
114
|
+
? patch.filters
|
|
115
|
+
: (isObject(base.filters) ? base.filters : {}),
|
|
116
|
+
sort: Object.hasOwn(patch, "sort") ? patch.sort : (base.sort ?? null),
|
|
117
|
+
hidden: Object.hasOwn(patch, "hidden")
|
|
118
|
+
? patch.hidden
|
|
119
|
+
: (Array.isArray(base.hidden) ? base.hidden : []),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validateViewColumns(config, fieldNames) {
|
|
124
|
+
const known = new Set(fieldNames);
|
|
125
|
+
const used = [
|
|
126
|
+
...Object.keys(config.filters),
|
|
127
|
+
...(config.sort ? [config.sort.column] : []),
|
|
128
|
+
...config.hidden,
|
|
129
|
+
];
|
|
130
|
+
const unknown = [...new Set(used.filter((field) => !known.has(field)))];
|
|
131
|
+
if (unknown.length > 0) {
|
|
132
|
+
badViewConfig(
|
|
133
|
+
`Unknown view field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. Available fields: ${fieldNames.join(", ") || "(none)"}.`,
|
|
134
|
+
"UNKNOWN_VIEW_FIELD",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return config;
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@montytools/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/TomasMonty/monty-v2.git",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"prepack": "node scripts/bundle-template.mjs",
|
|
24
|
-
"typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs && node --check lib/schemaCodegen.mjs && node --check lib/schemaPull.mjs && node --check scripts/schema-roundtrip.mjs",
|
|
24
|
+
"typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs && node --check lib/schemaCodegen.mjs && node --check lib/schemaPull.mjs && node --check lib/views.mjs && node --check scripts/schema-roundtrip.mjs",
|
|
25
|
+
"test": "node --test test/views.test.mjs",
|
|
25
26
|
"postinstall": "node bin/postinstall.mjs",
|
|
26
27
|
"test:roundtrip": "node scripts/schema-roundtrip.mjs"
|
|
27
28
|
},
|
|
@@ -65,20 +65,44 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
|
|
|
65
65
|
`monty schema` (JSON on stdout); change it by editing that JSON and
|
|
66
66
|
running `monty schema set <file>` — validated server-side, additive by
|
|
67
67
|
default. On workspace-owned apps, `monty.config.ts` edits do NOT change
|
|
68
|
-
the schema.
|
|
69
|
-
a
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
the schema. Give every field a `description` and every enum/multiSelect
|
|
69
|
+
a `valueDescriptions` map saying WHEN each option applies — that's the
|
|
70
|
+
guidance later record-writing agents follow. Declare a page in the
|
|
71
|
+
manifest BEFORE shipping its route — a save with an undeclared route
|
|
72
|
+
refuses with the fix. Field names `_*`, `updatedAt`, `createdBy` are
|
|
73
|
+
reserved.
|
|
74
|
+
6. **Prefer a saved view for filtered tables.** If the request is
|
|
75
|
+
one table with different filters, sorting, or hidden columns, use
|
|
76
|
+
`monty views set <table> <name> ...`. For example, an evaluation queue is
|
|
77
|
+
`monty views set leads "Evaluate" --filter '{"pipelineId":null}'`.
|
|
78
|
+
Change selected properties or rename it later with
|
|
79
|
+
`monty views update <table> <name> ...`; omitted properties stay unchanged.
|
|
80
|
+
If that table needs actions, compose `RecordPage` from
|
|
81
|
+
`@montytools/sdk/react` and add typed row/header/selection/record actions
|
|
82
|
+
with the controls from `@montytools/sdk/ui`. Write the whole page yourself
|
|
83
|
+
only when the shared components no longer fit. Custom pages remain the
|
|
84
|
+
escape hatch.
|
|
85
|
+
7. **UI is stock shadcn** (preset already wired). Add curated components with
|
|
72
86
|
`monty add <name>`; browse with `monty components` / `monty docs <name>`.
|
|
73
87
|
How pages should LOOK — Lyra surfaces, dark-only, the chart language — is
|
|
74
88
|
the `monty-design` skill; read it before styling any page.
|
|
75
|
-
|
|
89
|
+
8. **Errors are instructions.** Every failure prints
|
|
76
90
|
`[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
|
|
77
91
|
Typecheck failures block the save by design.
|
|
78
|
-
|
|
92
|
+
9. **Edits are real.** The dev shell reads and writes the app's LIVE
|
|
79
93
|
records — there is one set of data, and every write journals into the
|
|
80
94
|
app's Activity. Exercise the app for real; clean up test rows you
|
|
81
95
|
create; never seed junk into a team's working tables.
|
|
96
|
+
10. **Report platform failures when sharing is enabled.** `monty support
|
|
97
|
+
status` shows whether a workspace admin has allowed agent support
|
|
98
|
+
reports. If enabled, submit one report when a Monty platform error still
|
|
99
|
+
blocks the task after you followed its `Fix` and retried, or when the
|
|
100
|
+
requested work needs a missing Monty platform feature. Summarize the
|
|
101
|
+
thread. State the observed problem, expected behavior, attempted fixes,
|
|
102
|
+
and exact error. Do not paste the full conversation. If sharing is off,
|
|
103
|
+
ask the user before running `monty support enable --yes`; never grant
|
|
104
|
+
consent yourself. Do not file reports for ordinary app-code bugs or send
|
|
105
|
+
duplicates for the same incident.
|
|
82
106
|
|
|
83
107
|
## CLI reference
|
|
84
108
|
|
|
@@ -93,5 +117,7 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
|
|
|
93
117
|
| `monty add <name…>` | install curated shadcn components |
|
|
94
118
|
| `monty schema [slug]` | print the app's stored manifest (tables, pages, metrics) as JSON |
|
|
95
119
|
| `monty schema set <file\|->` | write an edited manifest back (validated, CAS, additive by default) |
|
|
120
|
+
| `monty views <list\|set\|update\|remove> <table>` | manage the shared saved views on a system record page |
|
|
96
121
|
| `monty save ["what changed"]` | push the working copy to the cloud copy, like `git push main` (build + typecheck gate it) |
|
|
122
|
+
| `monty support <status\|enable\|disable\|submit>` | manage consent and send a bounded agent-authored platform report |
|
|
97
123
|
| `monty skills` | (re)install this skill for your agent |
|
|
@@ -40,7 +40,16 @@ normal operating work; that is what this command is for.
|
|
|
40
40
|
values come from each row, and re-running never duplicates.
|
|
41
41
|
2. **Schema first, rows second.** Field names come from
|
|
42
42
|
`monty data schema`, exact spelling. The server stores what you send —
|
|
43
|
-
a misspelled field is silently a new field, not an error.
|
|
43
|
+
a misspelled field is silently a new field, not an error. The schema's
|
|
44
|
+
`description` and `enumDescriptions` annotations are the app's own
|
|
45
|
+
instructions for filling a field in: where a field has options (an
|
|
46
|
+
`enum`, alone or as array items), pick the option whose description
|
|
47
|
+
matches the situation — never your own reading of the option's name,
|
|
48
|
+
and never a value outside the list. A field without options has no
|
|
49
|
+
fixed vocabulary, so its `description` plus the conventions visible in
|
|
50
|
+
existing rows (`list` first) are the contract — write explicit,
|
|
51
|
+
consistently formatted values, not free-hand variants of the same
|
|
52
|
+
thing.
|
|
44
53
|
3. **Every write is real** — operating work targets the team's data, and
|
|
45
54
|
the app's Activity journals it. Destructive verbs (`remove`, bulk
|
|
46
55
|
`update`) deserve a confirmation with the user unless they clearly
|
|
@@ -57,6 +66,13 @@ normal operating work; that is what this command is for.
|
|
|
57
66
|
7. **Rows come back flattened**: your fields at the top level plus system
|
|
58
67
|
fields `_id`, `_creationTime`, `updatedAt`, `createdBy`. System fields
|
|
59
68
|
are read-only — never send them in `--data`.
|
|
69
|
+
8. **File fields hold descriptors, never bytes or paths.** A `file`-typed
|
|
70
|
+
field stores exactly the object `monty data upload` prints
|
|
71
|
+
(`{id, name, contentType, size, uploadedAt, uploadedBy}`). Upload
|
|
72
|
+
first, then reference: use the attach flags to set an existing row's
|
|
73
|
+
field in one command, or paste the printed descriptor into `--data`
|
|
74
|
+
when inserting. Never hand-write a descriptor — an id that no upload
|
|
75
|
+
produced 404s on download.
|
|
60
76
|
|
|
61
77
|
## Command reference
|
|
62
78
|
|
|
@@ -69,6 +85,8 @@ normal operating work; that is what this command is for.
|
|
|
69
85
|
| `monty data upsert <table> --key <field[,field]> --data '<json\|[json,…]>'` | find-or-create matched on the key fields — the idempotent write |
|
|
70
86
|
| `monty data update <table> <id> --data '<json>' [--unset a,b]` | shallow-merge onto one row; `--unset` deletes fields |
|
|
71
87
|
| `monty data remove <table> <id>` | delete one row |
|
|
88
|
+
| `monty data upload <path> [--name N] [--type mime] [--table <t> --record <id> --field <f>]` | store a file (10MB cap); prints the descriptor a `file` field holds — the attach flags set it on an existing row in the same command |
|
|
89
|
+
| `monty data download <file-id> [--out path]` | fetch a stored file's bytes to disk; the id is the `id` key of a row's file descriptor |
|
|
72
90
|
|
|
73
91
|
All verbs take `--app <slug>`. Output is one JSON document on
|
|
74
92
|
stdout.
|
package/template/AGENTS.md
CHANGED
|
@@ -36,7 +36,10 @@ Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current`
|
|
|
36
36
|
rectangles on real axes. The `monty-design` skill is the full spec.
|
|
37
37
|
4. **Schema changes = edit `monty.config.ts` and save.** Types update
|
|
38
38
|
immediately. Prefer additive changes; give new fields `.optional()` or
|
|
39
|
-
`.default(...)` so existing records stay readable.
|
|
39
|
+
`.default(...)` so existing records stay readable. Describe fields for
|
|
40
|
+
the next agent that fills them in: `.describe("what this holds")` on any
|
|
41
|
+
field, and `montySelect`/`montyMultiSelect` instead of bare `z.enum` so
|
|
42
|
+
every option says WHEN it applies, not what the word means.
|
|
40
43
|
5. **You are not done until the work is saved.** After every meaningful
|
|
41
44
|
change verified in dev, run `monty save "<what changed>"` — it builds,
|
|
42
45
|
typechecks, and pushes the working copy to the cloud copy, like
|
|
@@ -46,7 +49,7 @@ Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current`
|
|
|
46
49
|
|
|
47
50
|
```ts
|
|
48
51
|
// monty.config.ts
|
|
49
|
-
import { defineApp, montyFileSchema } from "@montytools/sdk";
|
|
52
|
+
import { defineApp, montyFileSchema, montySelect } from "@montytools/sdk";
|
|
50
53
|
import { z } from "zod";
|
|
51
54
|
|
|
52
55
|
export const app = defineApp({
|
|
@@ -54,8 +57,12 @@ export const app = defineApp({
|
|
|
54
57
|
tables: {
|
|
55
58
|
expenses: z.object({
|
|
56
59
|
title: z.string().min(1),
|
|
57
|
-
amount: z.number().positive(),
|
|
58
|
-
status:
|
|
60
|
+
amount: z.number().positive().describe("Receipt total in USD, tax included."),
|
|
61
|
+
status: montySelect(["draft", "submitted", "approved"], {
|
|
62
|
+
draft: "Still being filled in, not yet sent for review.",
|
|
63
|
+
submitted: "Awaiting a manager's decision.",
|
|
64
|
+
approved: "Cleared for reimbursement.",
|
|
65
|
+
}).default("draft"),
|
|
59
66
|
assigneeId: z.string().optional(), // userId from useMembers()
|
|
60
67
|
receipt: montyFileSchema.optional(),
|
|
61
68
|
}),
|
|
@@ -146,6 +153,12 @@ export const app = defineApp({
|
|
|
146
153
|
|
|
147
154
|
Rules that matter:
|
|
148
155
|
|
|
156
|
+
- **A filtered or sorted slice of one table is a saved view.** Use
|
|
157
|
+
`monty views set <table> <name> ...` before writing a route. For example,
|
|
158
|
+
`monty views set leads "Evaluate" --filter '{"pipelineId":null}'` creates
|
|
159
|
+
a shared record view without React. Use `monty views update` to change only
|
|
160
|
+
selected properties or rename it. Custom pages remain available when the
|
|
161
|
+
requested interaction, multi-table layout, or visualization needs code.
|
|
149
162
|
- **Custom pages wear the platform chrome from `@montytools/sdk/ui`** — the
|
|
150
163
|
SAME components the shell renders its own pages with, so your page is
|
|
151
164
|
indistinguishable from a system table view. Every custom page opens with
|
|
@@ -172,6 +185,13 @@ Rules that matter:
|
|
|
172
185
|
mode strips) and the Lyra table classes `SURFACE`/`THEAD`/`TH`/`ROW`/`CHIP`
|
|
173
186
|
for record-like tables (`<div className={SURFACE}><table>…`), identical to
|
|
174
187
|
the app's Configuration surfaces.
|
|
188
|
+
- **Extend records before rebuilding them.** For a custom page that is still
|
|
189
|
+
one table, use `RecordPage` from `@montytools/sdk/react`. Add behavior with
|
|
190
|
+
its typed `headerActions`, `rowActions`, `selectionActions`, and
|
|
191
|
+
`recordActions` callbacks. Use `RecordActionButton` and the lower-level
|
|
192
|
+
`RecordPage*`/`RecordTable*` components from `@montytools/sdk/ui` when you
|
|
193
|
+
need a different composition. Drop to a fully custom page only when those
|
|
194
|
+
components no longer fit.
|
|
175
195
|
- **Formulas are strings**, not functions — the Monty expression grammar
|
|
176
196
|
(`+ - * / %`, comparisons, `&& || !`, `IF/ROUND/ABS/MIN/MAX`,
|
|
177
197
|
`metrics.<name>`). A formula sees only fields declared ABOVE it. Type
|
|
@@ -216,9 +236,12 @@ secret keys, webhooks, clocks), create `server/index.ts` with named async
|
|
|
216
236
|
exports. Web-standard APIs only (`fetch`, `crypto`, `URL`, …) — no `node:`
|
|
217
237
|
imports; it runs on Cloudflare Workers when Live and inside `monty dev` in
|
|
218
238
|
a session. Every export gets `ctx`: `ctx.records` (full CRUD on all your
|
|
219
|
-
tables, including ones the UI never exposes), `ctx.
|
|
220
|
-
`
|
|
221
|
-
`
|
|
239
|
+
tables, including ones the UI never exposes), `ctx.files` (platform file
|
|
240
|
+
storage: `upload(data, {name?, contentType?}) → MontyFile`,
|
|
241
|
+
`download(fileOrId) → Blob`, `remove(fileOrId)` — same descriptors as the
|
|
242
|
+
`useUploadFile` hook, 10MB cap), `ctx.secrets` (see below), `ctx.viewer`
|
|
243
|
+
(who called: member session/visitor/schedule/none), and `ctx.track()`
|
|
244
|
+
(emit an event).
|
|
222
245
|
|
|
223
246
|
```ts
|
|
224
247
|
// server/index.ts
|
package/template/package.json
CHANGED