@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 +126 -3
- package/lib/schemaCodegen.mjs +18 -5
- package/package.json +1 -1
- package/skills/monty-build/SKILL.md +6 -3
- package/skills/monty-operate/SKILL.md +19 -1
- package/template/AGENTS.md +17 -7
- package/template/package.json +1 -1
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.
|
|
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).`);
|
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/package.json
CHANGED
|
@@ -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.
|
|
69
|
-
a
|
|
70
|
-
|
|
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.
|
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
|
}),
|
|
@@ -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.
|
|
220
|
-
`
|
|
221
|
-
`
|
|
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
|
package/template/package.json
CHANGED