@montytools/cli 0.5.1 → 0.5.2

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 CHANGED
@@ -984,7 +984,7 @@ async function freePort(start) {
984
984
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
985
985
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
986
986
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
987
- const MIN_SDK = "0.2.1";
987
+ const MIN_SDK = "0.2.2";
988
988
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
989
989
 
990
990
  function installedSdkVersion(appDir) {
@@ -2874,7 +2874,7 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
2874
2874
  // API. `app` is the plain slug — every app has ONE set of records.
2875
2875
  // Results are ONE JSON document on stdout so agents can pipe.
2876
2876
 
2877
- const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
2877
+ const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host", "name", "type", "out", "table", "record", "field"]);
2878
2878
 
2879
2879
  // rest, minus flags AND their values — `monty data list leads --app crm`
2880
2880
  // must not read "crm" as a positional. Boolean flags have no value and
@@ -2936,7 +2936,7 @@ async function dataAuth() {
2936
2936
  if (!r.ok || !body?.token) {
2937
2937
  fail(body?.code ?? `HTTP_${r.status}`, body?.fix ?? "Minting a workspace token failed. Run `monty login`, then retry.");
2938
2938
  }
2939
- return { convexUrl, token: body.token };
2939
+ return { host, convexUrl, token: body.token };
2940
2940
  }
2941
2941
 
2942
2942
  // One records function over Convex's public HTTP API (plain-JSON format —
@@ -2990,10 +2990,57 @@ function dataUsage() {
2990
2990
  console.log(" update <table> <id> --data '<json>' [--unset field,field]");
2991
2991
  console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
2992
2992
  console.log(" remove <table> <id>");
2993
+ console.log(" upload <path> [--name N] [--type mime] store a file (10MB cap); prints the descriptor to put in a `file` field");
2994
+ console.log(" [--table <t> --record <id> --field <f>] …and set that row's field in the same command");
2995
+ console.log(" download <file-id> [--out path] fetch a stored file's bytes (id from a row's file field)");
2993
2996
  console.log("target: --app <slug> (or run inside the app folder)");
2994
2997
  process.exit(1);
2995
2998
  }
2996
2999
 
3000
+ // Uploads without --type get the MIME their extension implies; unknown
3001
+ // extensions stay application/octet-stream (the server stores, never sniffs).
3002
+ const MIME_BY_EXT = {
3003
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
3004
+ webp: "image/webp", svg: "image/svg+xml", ico: "image/x-icon",
3005
+ pdf: "application/pdf", json: "application/json", csv: "text/csv",
3006
+ txt: "text/plain", md: "text/markdown", html: "text/html",
3007
+ zip: "application/zip", mp3: "audio/mpeg", wav: "audio/wav",
3008
+ mp4: "video/mp4", webm: "video/webm",
3009
+ };
3010
+
3011
+ function inferContentType(name) {
3012
+ const ext = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
3013
+ return (ext && MIME_BY_EXT[ext]) || "application/octet-stream";
3014
+ }
3015
+
3016
+ const DATA_MAX_FILE_BYTES = 10 * 1024 * 1024;
3017
+
3018
+ // POST/GET on the host's /api/files — the same rail the SDK file helpers
3019
+ // ride, authorized with the invocation's 5-minute workspace token.
3020
+ async function callFiles(method, params, auth, app, opts = {}) {
3021
+ const url = new URL("/api/files", auth.host);
3022
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
3023
+ let r;
3024
+ try {
3025
+ r = await fetch(url, {
3026
+ method,
3027
+ headers: {
3028
+ authorization: `Bearer ${auth.token}`,
3029
+ "x-monty-app": app,
3030
+ ...(opts.contentType ? { "content-type": opts.contentType } : {}),
3031
+ },
3032
+ ...(opts.body !== undefined ? { body: opts.body } : {}),
3033
+ });
3034
+ } catch {
3035
+ fail("HOST_UNREACHABLE", `The file endpoint at ${auth.host} did not answer — check the network and retry.`);
3036
+ }
3037
+ if (!r.ok) {
3038
+ const err = await r.json().catch(() => null);
3039
+ fail(err?.code ?? `HTTP_${r.status}`, err?.fix ?? "The file request failed. Retry; if it persists, report it.");
3040
+ }
3041
+ return r;
3042
+ }
3043
+
2997
3044
  async function data() {
2998
3045
  const [verb, table, id] = dataPositionals();
2999
3046
 
@@ -3028,6 +3075,82 @@ async function data() {
3028
3075
  return;
3029
3076
  }
3030
3077
 
3078
+ if (verb === "upload") {
3079
+ // The positional is a file PATH, not a table — the descriptor this
3080
+ // prints is what a `file`-typed field stores; --table/--record/--field
3081
+ // set it on an existing row in the same command.
3082
+ const path = table;
3083
+ if (!path) fail("MISSING_PATH", "Usage: monty data upload <path> [--name N] [--type mime] [--table <t> --record <id> --field <f>].");
3084
+ const attachTable = flag("table");
3085
+ const attachRecord = flag("record");
3086
+ const attachField = flag("field");
3087
+ const attachFlags = [attachTable, attachRecord, attachField].filter((f) => f !== undefined);
3088
+ if (attachFlags.length > 0 && attachFlags.length < 3) {
3089
+ fail("BAD_ATTACH", "Attaching needs all three of --table, --record, and --field — or none (then put the printed descriptor in a file field yourself).");
3090
+ }
3091
+ let bytes;
3092
+ try {
3093
+ bytes = readFileSync(path);
3094
+ } catch {
3095
+ fail("NO_SUCH_FILE", `Could not read "${path}" — check the path.`);
3096
+ }
3097
+ if (bytes.byteLength === 0) fail("EMPTY_FILE", `"${path}" is empty — nothing to upload.`);
3098
+ if (bytes.byteLength > DATA_MAX_FILE_BYTES) {
3099
+ fail("FILE_TOO_LARGE", `Files are limited to ${DATA_MAX_FILE_BYTES} bytes; "${path}" is ${bytes.byteLength}. Compress it first.`);
3100
+ }
3101
+ const name = flag("name") ?? basename(path);
3102
+ const contentType = flag("type") ?? inferContentType(name);
3103
+ const app = resolveDataApp();
3104
+ const auth = await dataAuth();
3105
+ if (attachFlags.length === 3) {
3106
+ // Prove the target row exists BEFORE storing bytes — a failed attach
3107
+ // after the upload would orphan the file.
3108
+ const row = await callRecords("query", "get", { app, table: attachTable, id: attachRecord }, auth);
3109
+ if (!row) fail("NOT_FOUND", `No record "${attachRecord}" in table "${attachTable}" — ids come from \`monty data list ${attachTable}\`.`);
3110
+ }
3111
+ const res = await callFiles("POST", { name }, auth, app, { body: bytes, contentType });
3112
+ const file = await res.json();
3113
+ if (attachFlags.length === 3) {
3114
+ await callRecords("mutation", "update", {
3115
+ app,
3116
+ table: attachTable,
3117
+ id: attachRecord,
3118
+ data: { [attachField]: file },
3119
+ }, auth);
3120
+ printJson({ ok: true, id: attachRecord, field: attachField, file });
3121
+ } else {
3122
+ printJson({ file });
3123
+ }
3124
+ return;
3125
+ }
3126
+
3127
+ if (verb === "download") {
3128
+ // The positional is the file id (a row's file-field descriptor carries
3129
+ // it), not a table.
3130
+ const fileId = table;
3131
+ if (!fileId) fail("MISSING_ID", "Usage: monty data download <file-id> [--out path] — ids come from a row's file field (its `id` key).");
3132
+ const app = resolveDataApp();
3133
+ const auth = await dataAuth();
3134
+ const res = await callFiles("GET", { id: fileId }, auth, app);
3135
+ const disposition = res.headers.get("content-disposition") ?? "";
3136
+ const remoteName = /filename="([^"]+)"/.exec(disposition)?.[1] ?? fileId;
3137
+ const out = flag("out") ?? remoteName;
3138
+ const body = Buffer.from(await res.arrayBuffer());
3139
+ try {
3140
+ writeFileSync(out, body);
3141
+ } catch {
3142
+ fail("WRITE_FAILED", `Could not write to "${out}" — check the path and permissions.`);
3143
+ }
3144
+ printJson({
3145
+ ok: true,
3146
+ path: out,
3147
+ name: remoteName,
3148
+ contentType: res.headers.get("content-type") ?? "application/octet-stream",
3149
+ size: body.byteLength,
3150
+ });
3151
+ return;
3152
+ }
3153
+
3031
3154
  const VERBS = new Set(["list", "get", "insert", "update", "upsert", "remove"]);
3032
3155
  if (!verb || !VERBS.has(verb)) dataUsage();
3033
3156
  if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
@@ -32,7 +32,8 @@ export function manifestHash(manifest) {
32
32
 
33
33
  const IMPORTABLE = [
34
34
  "defineApp", "formula", "lookup", "montyDate", "montyFileSchema",
35
- "montyMember", "montyMoney", "montyPercent", "montyRef", "rollup", "self",
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
- src = `z.enum(${JSON.stringify(spec.values)})`;
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
- src = `z.array(z.enum(${JSON.stringify(spec.values)}))`;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/TomasMonty/monty-v2.git",
@@ -65,9 +65,12 @@ 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. Declare a page in the manifest BEFORE shipping its route —
69
- a save with an undeclared route refuses with the fix. Field names `_*`,
70
- `updatedAt`, `createdBy` are reserved.
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.
71
74
  6. **UI is stock shadcn** (preset already wired). Add curated components with
72
75
  `monty add <name>`; browse with `monty components` / `monty docs <name>`.
73
76
  How pages should LOOK — Lyra surfaces, dark-only, the chart language — is
@@ -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.
@@ -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: z.enum(["draft", "submitted", "approved"]).default("draft"),
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
  }),
@@ -216,9 +223,12 @@ secret keys, webhooks, clocks), create `server/index.ts` with named async
216
223
  exports. Web-standard APIs only (`fetch`, `crypto`, `URL`, …) — no `node:`
217
224
  imports; it runs on Cloudflare Workers when Live and inside `monty dev` in
218
225
  a session. Every export gets `ctx`: `ctx.records` (full CRUD on all your
219
- tables, including ones the UI never exposes), `ctx.secrets` (see below),
220
- `ctx.viewer` (who called: member session/visitor/schedule/none), and
221
- `ctx.track()` (emit an event).
226
+ tables, including ones the UI never exposes), `ctx.files` (platform file
227
+ storage: `upload(data, {name?, contentType?}) → MontyFile`,
228
+ `download(fileOrId) → Blob`, `remove(fileOrId)` same descriptors as the
229
+ `useUploadFile` hook, 10MB cap), `ctx.secrets` (see below), `ctx.viewer`
230
+ (who called: member session/visitor/schedule/none), and `ctx.track()`
231
+ (emit an event).
222
232
 
223
233
  ```ts
224
234
  // server/index.ts
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.2.1",
13
+ "@montytools/sdk": "^0.2.2",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",