@montytools/cli 0.1.0
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/catalog.mjs +75 -0
- package/bin/monty.mjs +397 -0
- package/package.json +25 -0
- package/template/.env.example +3 -0
- package/template/AGENTS.md +133 -0
- package/template/components.json +28 -0
- package/template/index.html +12 -0
- package/template/monty.config.ts +20 -0
- package/template/package.json +37 -0
- package/template/src/components/ui/badge.tsx +49 -0
- package/template/src/components/ui/button.tsx +65 -0
- package/template/src/components/ui/card.tsx +103 -0
- package/template/src/components/ui/empty.tsx +101 -0
- package/template/src/components/ui/field.tsx +236 -0
- package/template/src/components/ui/input.tsx +19 -0
- package/template/src/components/ui/label.tsx +22 -0
- package/template/src/components/ui/select.tsx +192 -0
- package/template/src/components/ui/separator.tsx +28 -0
- package/template/src/components/ui/spinner.tsx +10 -0
- package/template/src/components/ui/table.tsx +114 -0
- package/template/src/index.css +134 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/main.tsx +24 -0
- package/template/src/routes/__root.tsx +5 -0
- package/template/src/routes/index.tsx +263 -0
- package/template/src/vite-env.d.ts +1 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +16 -0
package/bin/catalog.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The curated component catalog: one blessed, pre-vetted implementation per
|
|
2
|
+
// capability. Every entry is MIT-licensed, Radix-based, and styles itself
|
|
3
|
+
// through the app's semantic tokens — so it comes out in the Monty theme
|
|
4
|
+
// with no extra work. Bare names not listed here fall through to the core
|
|
5
|
+
// shadcn registry (dialog, tabs, popover, ...).
|
|
6
|
+
|
|
7
|
+
export const REGISTRIES = {
|
|
8
|
+
"@kibo-ui": "https://www.kibo-ui.com/r/{name}.json",
|
|
9
|
+
"@diceui": "https://diceui.com/r/{name}.json",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const CATALOG = {
|
|
13
|
+
"data-table": {
|
|
14
|
+
item: "@diceui/data-table",
|
|
15
|
+
// upstream registryDependencies omit skeleton (data-table-skeleton.tsx needs it)
|
|
16
|
+
also: ["skeleton"],
|
|
17
|
+
use: "sortable/filterable/paginated table — default for any list of records",
|
|
18
|
+
},
|
|
19
|
+
kanban: {
|
|
20
|
+
item: "@kibo-ui/kanban",
|
|
21
|
+
use: "drag-and-drop board grouped by a status/enum field",
|
|
22
|
+
},
|
|
23
|
+
calendar: {
|
|
24
|
+
item: "@kibo-ui/calendar",
|
|
25
|
+
use: "month grid showing records that have a date field",
|
|
26
|
+
},
|
|
27
|
+
gantt: {
|
|
28
|
+
item: "@kibo-ui/gantt",
|
|
29
|
+
use: "timeline view for records with start and end dates",
|
|
30
|
+
},
|
|
31
|
+
combobox: {
|
|
32
|
+
item: "@kibo-ui/combobox",
|
|
33
|
+
use: "searchable select — use instead of <Select> when options exceed ~10",
|
|
34
|
+
},
|
|
35
|
+
"file-upload": {
|
|
36
|
+
item: "@kibo-ui/dropzone",
|
|
37
|
+
use: "drag-and-drop file upload area",
|
|
38
|
+
},
|
|
39
|
+
editor: {
|
|
40
|
+
item: "@kibo-ui/editor",
|
|
41
|
+
use: "rich text editor (notes, descriptions, long-form fields)",
|
|
42
|
+
},
|
|
43
|
+
tags: {
|
|
44
|
+
item: "@kibo-ui/tags",
|
|
45
|
+
use: "multi-value tag input (labels, categories)",
|
|
46
|
+
},
|
|
47
|
+
status: {
|
|
48
|
+
item: "@kibo-ui/status",
|
|
49
|
+
use: "colored status dot + label for enum states",
|
|
50
|
+
},
|
|
51
|
+
"avatar-stack": {
|
|
52
|
+
item: "@kibo-ui/avatar-stack",
|
|
53
|
+
use: "overlapping member avatars (pairs with useMembers)",
|
|
54
|
+
},
|
|
55
|
+
rating: {
|
|
56
|
+
item: "@kibo-ui/rating",
|
|
57
|
+
use: "star rating input/display",
|
|
58
|
+
},
|
|
59
|
+
chart: {
|
|
60
|
+
item: "chart",
|
|
61
|
+
use: "themed charts (bar/line/area/pie) for dashboards and KPIs",
|
|
62
|
+
},
|
|
63
|
+
sidebar: {
|
|
64
|
+
item: "sidebar",
|
|
65
|
+
use: "collapsible app sidebar — use when the app grows past one page",
|
|
66
|
+
},
|
|
67
|
+
command: {
|
|
68
|
+
item: "command",
|
|
69
|
+
use: "command palette / cmd-K search",
|
|
70
|
+
},
|
|
71
|
+
sonner: {
|
|
72
|
+
item: "sonner",
|
|
73
|
+
use: "toast notifications for action feedback",
|
|
74
|
+
},
|
|
75
|
+
};
|
package/bin/monty.mjs
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// monty — the Monty platform CLI. Output is agent-shaped: structured
|
|
3
|
+
// single-line events, no spinners, instruction-shaped errors, and a
|
|
4
|
+
// deterministic final line (`deployed: …` / `error: …`).
|
|
5
|
+
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { createInterface } from "node:readline/promises";
|
|
12
|
+
import { CATALOG, REGISTRIES } from "./catalog.mjs";
|
|
13
|
+
|
|
14
|
+
const CONFIG_DIR = join(homedir(), ".monty");
|
|
15
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
16
|
+
const DEFAULT_HOST = "https://usemonty.dev";
|
|
17
|
+
|
|
18
|
+
const [, , command, ...rest] = process.argv;
|
|
19
|
+
|
|
20
|
+
function flag(name) {
|
|
21
|
+
const i = rest.indexOf(`--${name}`);
|
|
22
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fail(code, fix) {
|
|
26
|
+
console.error(`error: [MontyError ${code}] Fix: ${fix}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadConfig() {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── monty login ────────────────────────────────────────────────────────────
|
|
39
|
+
async function login() {
|
|
40
|
+
const host = flag("host") ?? DEFAULT_HOST;
|
|
41
|
+
let key = flag("key");
|
|
42
|
+
if (!key) {
|
|
43
|
+
console.log(`open: ${host}/cli-auth (sign in, create a CLI key)`);
|
|
44
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
key = (await rl.question("paste key: ")).trim();
|
|
46
|
+
rl.close();
|
|
47
|
+
}
|
|
48
|
+
if (!/^mk_[0-9a-f]{48}$/.test(key)) {
|
|
49
|
+
fail("INVALID_CLI_KEY", `That does not look like a Monty CLI key (mk_ + 48 hex chars). Create one at ${host}/cli-auth.`);
|
|
50
|
+
}
|
|
51
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
52
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ host, key }, null, 2) + "\n");
|
|
53
|
+
console.log(`logged-in: ${host} (key saved to ~/.monty/config.json)`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── monty create ───────────────────────────────────────────────────────────
|
|
57
|
+
async function create() {
|
|
58
|
+
const slug = rest.find((a) => !a.startsWith("--"));
|
|
59
|
+
if (!slug || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
|
60
|
+
fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens (e.g. "standup-notes").');
|
|
61
|
+
}
|
|
62
|
+
const name =
|
|
63
|
+
flag("name") ??
|
|
64
|
+
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
65
|
+
const icon = flag("icon") ?? "layout-grid";
|
|
66
|
+
const target = join(process.cwd(), flag("dir") ?? slug);
|
|
67
|
+
if (existsSync(target)) {
|
|
68
|
+
fail("DIR_EXISTS", `${target} already exists. Pick another slug or remove the directory.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Template is bundled into the published package (../template). Fall back to
|
|
72
|
+
// the monorepo path when running the CLI in-place during development.
|
|
73
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
74
|
+
const templateDir = [
|
|
75
|
+
join(pkgRoot, "template"),
|
|
76
|
+
join(pkgRoot, "..", "template"),
|
|
77
|
+
].find((d) => existsSync(join(d, "monty.config.ts")));
|
|
78
|
+
if (!templateDir) {
|
|
79
|
+
fail("TEMPLATE_MISSING", "The Monty app template is missing from this CLI install. Reinstall the monty CLI.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`create: ${slug} -> ${target}`);
|
|
83
|
+
cpSync(templateDir, target, {
|
|
84
|
+
recursive: true,
|
|
85
|
+
filter: (src) => {
|
|
86
|
+
const base = basename(src);
|
|
87
|
+
return !["node_modules", "dist", ".monty", ".env.local", "routeTree.gen.ts"].includes(base);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Stamp identity into the copied files.
|
|
92
|
+
const configPath = join(target, "monty.config.ts");
|
|
93
|
+
writeFileSync(
|
|
94
|
+
configPath,
|
|
95
|
+
readFileSync(configPath, "utf8")
|
|
96
|
+
.replace(/slug: "[^"]*"/, `slug: "${slug}"`)
|
|
97
|
+
.replace(/name: "[^"]*"/, `name: "${name}"`)
|
|
98
|
+
.replace(/icon: "[^"]*"/, `icon: "${icon}"`),
|
|
99
|
+
);
|
|
100
|
+
const pkgPath = join(target, "package.json");
|
|
101
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
102
|
+
pkg.name = slug;
|
|
103
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
104
|
+
const htmlPath = join(target, "index.html");
|
|
105
|
+
writeFileSync(
|
|
106
|
+
htmlPath,
|
|
107
|
+
readFileSync(htmlPath, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${name}</title>`),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Client config comes from the platform — public values, no dashboard trip.
|
|
111
|
+
const host = loadConfig()?.host ?? DEFAULT_HOST;
|
|
112
|
+
try {
|
|
113
|
+
const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(target, ".env.local"),
|
|
116
|
+
`VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
|
|
117
|
+
);
|
|
118
|
+
console.log(`config: .env.local written from ${host}/api/config`);
|
|
119
|
+
} catch {
|
|
120
|
+
console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`created: ${target}`);
|
|
124
|
+
console.log(`next: cd ${relative(process.cwd(), target)} && pnpm install && monty dev`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── monty dev ──────────────────────────────────────────────────────────────
|
|
128
|
+
async function dev() {
|
|
129
|
+
const appDir = process.cwd();
|
|
130
|
+
if (!existsSync(join(appDir, "monty.config.ts"))) {
|
|
131
|
+
fail("NOT_A_MONTY_APP", "No monty.config.ts here. Run `monty dev` from your app's root directory.");
|
|
132
|
+
}
|
|
133
|
+
const meta = await compileConfig(appDir);
|
|
134
|
+
const host = loadConfig()?.host ?? DEFAULT_HOST;
|
|
135
|
+
const port = Number(flag("port") ?? 5173);
|
|
136
|
+
|
|
137
|
+
console.log(`dev: starting vite on :${port} (app "${meta.slug}")`);
|
|
138
|
+
const child = spawn("npx", ["vite", "dev", "--port", String(port), "--strictPort"], {
|
|
139
|
+
cwd: appDir,
|
|
140
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
141
|
+
});
|
|
142
|
+
let announced = false;
|
|
143
|
+
child.stdout.on("data", (chunk) => {
|
|
144
|
+
const text = chunk.toString();
|
|
145
|
+
process.stdout.write(text);
|
|
146
|
+
if (!announced && /localhost:\d+/.test(text)) {
|
|
147
|
+
announced = true;
|
|
148
|
+
console.log(`open: ${host}/apps/${meta.slug}?dev=http://localhost:${port}`);
|
|
149
|
+
console.log(`data: sandboxed to "${meta.slug}#dev" (live records untouched)`);
|
|
150
|
+
console.log(`ready: http://localhost:${port}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── monty add / components / docs ──────────────────────────────────────────
|
|
157
|
+
// Wraps the shadcn CLI behind the curated catalog: agents ask for a
|
|
158
|
+
// capability by plain name ("kanban") and get the blessed, theme-compatible
|
|
159
|
+
// implementation. Only catalog registries and core shadcn resolve.
|
|
160
|
+
|
|
161
|
+
function requireAppDir(cmd) {
|
|
162
|
+
const appDir = process.cwd();
|
|
163
|
+
if (!existsSync(join(appDir, "monty.config.ts"))) {
|
|
164
|
+
fail("NOT_A_MONTY_APP", `No monty.config.ts here. Run \`monty ${cmd}\` from your app's root directory.`);
|
|
165
|
+
}
|
|
166
|
+
return appDir;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// "kanban" -> "@kibo-ui/kanban"; bare unlisted names pass through to core
|
|
170
|
+
// shadcn; explicit @namespace items must belong to a supported registry.
|
|
171
|
+
function resolveComponent(name) {
|
|
172
|
+
if (CATALOG[name]) return CATALOG[name].item;
|
|
173
|
+
if (name.startsWith("@")) {
|
|
174
|
+
const ns = name.split("/")[0];
|
|
175
|
+
if (ns !== "@shadcn" && !REGISTRIES[ns]) {
|
|
176
|
+
fail("UNSUPPORTED_REGISTRY", `${ns} is not a supported registry. Run \`monty components\` for the curated catalog; core shadcn components install by bare name.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return name;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function add() {
|
|
183
|
+
const appDir = requireAppDir("add");
|
|
184
|
+
const names = rest.filter((a) => !a.startsWith("--"));
|
|
185
|
+
if (names.length === 0) {
|
|
186
|
+
fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available.");
|
|
187
|
+
}
|
|
188
|
+
const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
|
|
189
|
+
|
|
190
|
+
// --overwrite so shadcn never halts on a per-file prompt (an aborted prompt
|
|
191
|
+
// cancels ALL remaining writes). Existing files win instead: snapshot them
|
|
192
|
+
// and restore any the install replaced — the app's themed primitives stay,
|
|
193
|
+
// new files land alongside.
|
|
194
|
+
const snapshot = new Map();
|
|
195
|
+
for (const dir of ["src/components", "src/hooks", "src/lib"]) {
|
|
196
|
+
const abs = join(appDir, dir);
|
|
197
|
+
if (existsSync(abs)) for (const f of walk(abs)) snapshot.set(f, readFileSync(f));
|
|
198
|
+
}
|
|
199
|
+
run(appDir, "add", ["pnpm", "dlx", "shadcn@latest", "add", "--yes", "--overwrite", ...items],
|
|
200
|
+
`Could not add ${items.join(", ")}. If the name was a guess, run \`monty components\` for the curated catalog — core shadcn components (dialog, tabs, dropdown-menu, ...) install by bare name.`);
|
|
201
|
+
|
|
202
|
+
// The shadcn CLI rewrites imports and mangles paths outside its known
|
|
203
|
+
// aliases (e.g. @/config/*, @/types/*). The template's layout matches
|
|
204
|
+
// registry sources exactly, so for third-party items restore each newly
|
|
205
|
+
// created file verbatim from the registry JSON.
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
const [ns, name] = item.split("/");
|
|
208
|
+
const urlTemplate = REGISTRIES[ns];
|
|
209
|
+
if (!urlTemplate) continue;
|
|
210
|
+
const spec = await fetch(urlTemplate.replace("{name}", name)).then((r) => r.json()).catch(() => null);
|
|
211
|
+
for (const file of spec?.files ?? []) {
|
|
212
|
+
if (typeof file.content !== "string") continue;
|
|
213
|
+
const dest = join(appDir, (file.target || file.path).replace(/^~\//, ""));
|
|
214
|
+
if (existsSync(dest) && !snapshot.has(dest)) writeFileSync(dest, file.content);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let kept = 0;
|
|
219
|
+
for (const [file, buf] of snapshot) {
|
|
220
|
+
if (existsSync(file) && !readFileSync(file).equals(buf)) {
|
|
221
|
+
writeFileSync(file, buf);
|
|
222
|
+
kept++;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (kept > 0) console.log(`kept: ${kept} existing file(s) unchanged (your themed versions win)`);
|
|
226
|
+
console.log(`added: ${items.join(", ")} -> src/components (already themed; import and compose)`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function components() {
|
|
230
|
+
const query = rest.filter((a) => !a.startsWith("--")).join(" ").toLowerCase();
|
|
231
|
+
const entries = Object.entries(CATALOG).filter(
|
|
232
|
+
([name, { item, use }]) => !query || `${name} ${item} ${use}`.toLowerCase().includes(query),
|
|
233
|
+
);
|
|
234
|
+
const width = Math.max(...Object.keys(CATALOG).map((n) => n.length));
|
|
235
|
+
const itemWidth = Math.max(...Object.values(CATALOG).map((c) => c.item.length));
|
|
236
|
+
console.log(`components: ${entries.length} curated (install with \`monty add <name>\`)`);
|
|
237
|
+
for (const [name, { item, use }] of entries) {
|
|
238
|
+
console.log(` ${name.padEnd(width)} ${item.padEnd(itemWidth)} ${use}`);
|
|
239
|
+
}
|
|
240
|
+
console.log("core: any shadcn component installs by bare name (dialog, tabs, dropdown-menu, popover, tooltip, sheet, checkbox, textarea, ...)");
|
|
241
|
+
console.log("docs: `monty docs <name>` shows a component's source before installing");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function docs() {
|
|
245
|
+
const appDir = requireAppDir("docs");
|
|
246
|
+
const name = rest.find((a) => !a.startsWith("--"));
|
|
247
|
+
if (!name) {
|
|
248
|
+
fail("NO_COMPONENT", "Usage: monty docs <name> — run `monty components` to see what's available.");
|
|
249
|
+
}
|
|
250
|
+
run(appDir, "docs", ["pnpm", "dlx", "shadcn@latest", "view", resolveComponent(name)],
|
|
251
|
+
`Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── monty deploy ───────────────────────────────────────────────────────────
|
|
255
|
+
async function deploy() {
|
|
256
|
+
const appDir = process.cwd();
|
|
257
|
+
if (!existsSync(join(appDir, "monty.config.ts"))) {
|
|
258
|
+
fail("NOT_A_MONTY_APP", "No monty.config.ts here. Run `monty deploy` from your app's root directory.");
|
|
259
|
+
}
|
|
260
|
+
const config = loadConfig();
|
|
261
|
+
if (!config?.key) {
|
|
262
|
+
fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 1) Compile monty.config.ts → { slug, name, icon, schemaJson } using the
|
|
266
|
+
// app's OWN zod/sdk instances (esbuild-bundled, run in a subprocess).
|
|
267
|
+
console.log("compile: monty.config.ts");
|
|
268
|
+
const meta = await compileConfig(appDir);
|
|
269
|
+
console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
|
|
270
|
+
|
|
271
|
+
// 2) Fail fast locally before any upload. Build FIRST — it also generates
|
|
272
|
+
// src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
|
|
273
|
+
run(appDir, "build", ["npx", "vite", "build"],
|
|
274
|
+
"The production build failed. Read the vite error above; it names the file to fix.");
|
|
275
|
+
run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
|
|
276
|
+
"TypeScript errors above. Fix them in the listed files; `monty deploy` never uploads code that does not compile.");
|
|
277
|
+
|
|
278
|
+
// 3) Multipart POST to the host.
|
|
279
|
+
const dist = join(appDir, "dist");
|
|
280
|
+
const files = walk(dist);
|
|
281
|
+
const form = new FormData();
|
|
282
|
+
form.set("monty", JSON.stringify(meta));
|
|
283
|
+
let total = 0;
|
|
284
|
+
for (const file of files) {
|
|
285
|
+
const rel = relative(dist, file).split("\\").join("/");
|
|
286
|
+
const buf = readFileSync(file);
|
|
287
|
+
total += buf.byteLength;
|
|
288
|
+
form.set(rel, new Blob([buf]), rel);
|
|
289
|
+
}
|
|
290
|
+
console.log(`upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`);
|
|
291
|
+
const res = await fetch(`${config.host}/api/deploy`, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: { authorization: `Bearer ${config.key}` },
|
|
294
|
+
body: form,
|
|
295
|
+
});
|
|
296
|
+
const body = await res.json().catch(() => null);
|
|
297
|
+
if (!res.ok || !body?.ok) {
|
|
298
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
|
|
299
|
+
}
|
|
300
|
+
console.log(`origin: ${body.origin}`);
|
|
301
|
+
console.log(`deployed: ${body.url} (version ${body.version})`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function compileConfig(appDir) {
|
|
305
|
+
const { build } = await import("esbuild");
|
|
306
|
+
const tmpDir = join(appDir, ".monty");
|
|
307
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
308
|
+
const entry = join(tmpDir, "compile-entry.mjs");
|
|
309
|
+
const out = join(tmpDir, "compile-out.mjs");
|
|
310
|
+
writeFileSync(entry, [
|
|
311
|
+
`import { app } from "../monty.config";`,
|
|
312
|
+
`import { compileApp } from "@montytools/sdk/compile";`,
|
|
313
|
+
`process.stdout.write(JSON.stringify(compileApp(app)));`,
|
|
314
|
+
].join("\n"));
|
|
315
|
+
try {
|
|
316
|
+
await build({
|
|
317
|
+
entryPoints: [entry],
|
|
318
|
+
outfile: out,
|
|
319
|
+
bundle: true,
|
|
320
|
+
platform: "node",
|
|
321
|
+
format: "esm",
|
|
322
|
+
target: "node22",
|
|
323
|
+
absWorkingDir: appDir,
|
|
324
|
+
logLevel: "silent",
|
|
325
|
+
});
|
|
326
|
+
const result = spawnSync(process.execPath, [out], { encoding: "utf8" });
|
|
327
|
+
if (result.status !== 0) {
|
|
328
|
+
fail("CONFIG_COMPILE_FAILED", `monty.config.ts threw while loading:\n${result.stderr}\nFix the config (it must only call defineApp with zod tables).`);
|
|
329
|
+
}
|
|
330
|
+
return JSON.parse(result.stdout);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
if (e?.errors) {
|
|
333
|
+
fail("CONFIG_COMPILE_FAILED", `esbuild could not bundle monty.config.ts: ${e.errors[0]?.text ?? e.message}`);
|
|
334
|
+
}
|
|
335
|
+
throw e;
|
|
336
|
+
} finally {
|
|
337
|
+
rmSync(entry, { force: true });
|
|
338
|
+
rmSync(out, { force: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function run(cwd, label, argv, fixOnFail) {
|
|
343
|
+
console.log(`${label}: ${argv.join(" ")}`);
|
|
344
|
+
// stdin ignored: interactive prompts (e.g. shadcn's per-file overwrite
|
|
345
|
+
// confirmation) take their safe default instead of blocking an agent.
|
|
346
|
+
const result = spawnSync(argv[0], argv.slice(1), { cwd, stdio: ["ignore", "inherit", "inherit"] });
|
|
347
|
+
if (result.status !== 0) {
|
|
348
|
+
fail(label.toUpperCase() + "_FAILED", fixOnFail);
|
|
349
|
+
}
|
|
350
|
+
console.log(`${label}: ok`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function walk(dir) {
|
|
354
|
+
const out = [];
|
|
355
|
+
for (const name of readdirSync(dir)) {
|
|
356
|
+
const p = join(dir, name);
|
|
357
|
+
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
358
|
+
else out.push(p);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── dispatch ───────────────────────────────────────────────────────────────
|
|
364
|
+
switch (command) {
|
|
365
|
+
case "login":
|
|
366
|
+
await login();
|
|
367
|
+
break;
|
|
368
|
+
case "create":
|
|
369
|
+
await create();
|
|
370
|
+
break;
|
|
371
|
+
case "dev":
|
|
372
|
+
await dev();
|
|
373
|
+
break;
|
|
374
|
+
case "add":
|
|
375
|
+
await add();
|
|
376
|
+
break;
|
|
377
|
+
case "components":
|
|
378
|
+
case "search":
|
|
379
|
+
components();
|
|
380
|
+
break;
|
|
381
|
+
case "docs":
|
|
382
|
+
await docs();
|
|
383
|
+
break;
|
|
384
|
+
case "deploy":
|
|
385
|
+
await deploy();
|
|
386
|
+
break;
|
|
387
|
+
default:
|
|
388
|
+
console.log("usage: monty <login|create|dev|add|components|docs|deploy>");
|
|
389
|
+
console.log(" login --host <url> --key <mk_...> save CLI credentials");
|
|
390
|
+
console.log(" create <slug> [--name N] [--icon I] stamp a new app from the template");
|
|
391
|
+
console.log(" dev [--port 5173] run the app locally (sandboxed data)");
|
|
392
|
+
console.log(" add <name...> install curated UI components (see `monty components`)");
|
|
393
|
+
console.log(" components [query] list the curated component catalog");
|
|
394
|
+
console.log(" docs <name> view a component's source before installing");
|
|
395
|
+
console.log(" deploy build + upload this app");
|
|
396
|
+
process.exit(command ? 1 : 0);
|
|
397
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@montytools/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"monty": "./bin/monty.mjs"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"template"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prepack": "node scripts/bundle-template.mjs",
|
|
17
|
+
"typecheck": "node --check bin/monty.mjs"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"esbuild": "^0.28.1"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# You are building a Monty app
|
|
2
|
+
|
|
3
|
+
Monty is a work OS: your app runs inside a team's workspace, on shared reactive
|
|
4
|
+
data, with auth and deployment handled by the platform. **You only write product
|
|
5
|
+
logic.** Everything below is the complete contract.
|
|
6
|
+
|
|
7
|
+
## The three files that matter
|
|
8
|
+
|
|
9
|
+
| File | What it is |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `monty.config.ts` | Your data schema — plain zod. The ONLY place data shapes are defined. Also `name` + `icon` (any [lucide](https://lucide.dev/icons) icon name, e.g. `"receipt"`, `"users"`) — Monty renders your app's logo tile from it. |
|
|
12
|
+
| `src/routes/` | Your UI — TanStack Router file routes (`index.tsx` = `/`). |
|
|
13
|
+
| `src/main.tsx` | Wiring. Do not edit. |
|
|
14
|
+
|
|
15
|
+
## Rules (violations break the app)
|
|
16
|
+
|
|
17
|
+
1. **Never import `convex`, `@clerk/*`, or talk to a database or API directly.**
|
|
18
|
+
All data goes through `@montytools/sdk/react` hooks. Auth, tenancy, and realtime
|
|
19
|
+
are handled below you — never write them.
|
|
20
|
+
2. **Never ask for or pass a workspace/tenant id.** The platform injects it
|
|
21
|
+
from the verified session; it is not part of any API you can see.
|
|
22
|
+
3. **UI is shadcn/ui, preconfigured — never build components from scratch.**
|
|
23
|
+
Before building any UI piece, run `monty components`. If the capability is
|
|
24
|
+
listed (data tables, kanban, calendar, combobox, file upload, rich text,
|
|
25
|
+
charts, …) install the curated implementation with `monty add <name>`;
|
|
26
|
+
core shadcn components install by bare name (`monty add dialog tabs`).
|
|
27
|
+
Everything lands in `src/components/ui/*` already carrying the Monty
|
|
28
|
+
theme. `monty docs <name>` shows a component's source before installing.
|
|
29
|
+
Icons from `lucide-react`. Don't install other component libraries or
|
|
30
|
+
write raw-color CSS — use semantic tokens (`bg-background`,
|
|
31
|
+
`text-muted-foreground`, …). Don't edit `src/index.css` theme tokens.
|
|
32
|
+
4. **Schema changes = edit `monty.config.ts` and save.** Types update
|
|
33
|
+
immediately. Prefer additive changes; give new fields `.optional()` or
|
|
34
|
+
`.default(...)` so existing records stay readable.
|
|
35
|
+
|
|
36
|
+
## Data: define, then use
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// monty.config.ts
|
|
40
|
+
export const app = defineApp({
|
|
41
|
+
slug: "expenses",
|
|
42
|
+
tables: {
|
|
43
|
+
expenses: z.object({
|
|
44
|
+
title: z.string().min(1),
|
|
45
|
+
amount: z.number().positive(),
|
|
46
|
+
status: z.enum(["draft", "submitted", "approved"]).default("draft"),
|
|
47
|
+
assigneeId: z.string().optional(), // userId from useMembers()
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { useList, useRecord, useInsert, useUpdate, useRemove, useMembers } from "@montytools/sdk/react";
|
|
55
|
+
import { app } from "../../monty.config";
|
|
56
|
+
|
|
57
|
+
const { data, status, loadMore } = useList(app, "expenses", {
|
|
58
|
+
filter: { status: "submitted" }, // equality on schema fields only
|
|
59
|
+
order: "desc", // by creation time
|
|
60
|
+
limit: 50,
|
|
61
|
+
});
|
|
62
|
+
// data: rows typed from your zod schema + { _id, _creationTime, updatedAt, createdBy }
|
|
63
|
+
// status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"
|
|
64
|
+
// LIVE: any teammate's write re-renders this. Never poll, refetch, or cache.
|
|
65
|
+
|
|
66
|
+
const insert = useInsert(app, "expenses");
|
|
67
|
+
await insert({ title: "Lunch", amount: 12 }); // defaults applied, validated
|
|
68
|
+
|
|
69
|
+
const update = useUpdate(app, "expenses");
|
|
70
|
+
await update(row._id, { status: "approved" }); // shallow-merge patch
|
|
71
|
+
await update(row._id, { assigneeId: null }); // null CLEARS an .optional() field
|
|
72
|
+
// Counters: read-modify-write (`update(id, { votes: row.votes + 1 })`) is the
|
|
73
|
+
// blessed pattern. It is last-write-wins — two simultaneous clicks can lose
|
|
74
|
+
// one. Fine for votes/likes; if exact counts matter, model events as ROWS in
|
|
75
|
+
// their own table and count client-side.
|
|
76
|
+
|
|
77
|
+
const remove = useRemove(app, "expenses");
|
|
78
|
+
await remove(row._id);
|
|
79
|
+
|
|
80
|
+
const one = useRecord(app, "expenses", idOrNull); // row | null(missing or no selection) | undefined(loading)
|
|
81
|
+
const members = useMembers(); // [{ userId, name, email, imageUrl, role }]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Errors are instructions
|
|
85
|
+
|
|
86
|
+
Every platform error is one line shaped like:
|
|
87
|
+
`[MontyError VALIDATION] at expenses.status: expected "draft"|"submitted"|"approved", got "done". Fix: ...`
|
|
88
|
+
|
|
89
|
+
**Do exactly what the `Fix:` says.** Codes you may see:
|
|
90
|
+
|
|
91
|
+
| Code | Meaning |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `VALIDATION` | Payload doesn't match your zod schema (unknown fields are also an error) — fix the payload or the schema. |
|
|
94
|
+
| `UNKNOWN_TABLE` | Table name not in `monty.config.ts` `tables`. |
|
|
95
|
+
| `NOT_FOUND` | Stale, foreign, or WRONG-TABLE record id — ids come from `useList`/`useRecord` on the same table; never hard-code or mix them. |
|
|
96
|
+
| `INVALID_SCHEMA` | A table field uses a reserved name (`_*`, `updatedAt`, `createdBy`) — rename it. |
|
|
97
|
+
| `SCHEMA_DRIFT` (warning) | Stored rows predate your latest schema change; nothing crashes, but make changed fields `.optional()`/`.default(...)`. |
|
|
98
|
+
| `UNAUTHENTICATED` / `NO_ACTIVE_WORKSPACE` | App isn't running through the Monty host/dev shell. |
|
|
99
|
+
| `MISSING_ENV` / `NO_PROVIDER` | `.env.local` or the `<MontyProvider>` in `main.tsx` was removed. |
|
|
100
|
+
|
|
101
|
+
## Dev loop
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
pnpm dev # Vite + HMR on :5173; sign in with your workspace account
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Headless? Verify with `pnpm exec vite build` then `pnpm exec tsc --noEmit` — in
|
|
108
|
+
that order: the first build generates `src/routeTree.gen.ts`, without which
|
|
109
|
+
typecheck fails on a fresh app. `monty deploy` runs both itself (it never
|
|
110
|
+
uploads code that doesn't compile), so deploy is self-verifying.
|
|
111
|
+
|
|
112
|
+
**Driving your app in a browser (agents):** while `monty dev` runs, opening
|
|
113
|
+
`http://localhost:5173` is ALREADY AUTHENTICATED — no sign-in screen (the dev
|
|
114
|
+
server mints short-lived workspace tokens from the CLI login). Point
|
|
115
|
+
Playwright or any browser automation at it, click through your app against
|
|
116
|
+
live sandboxed data, and read your errors in the `monty dev` terminal
|
|
117
|
+
(`[browser:error] …` lines). Edit → HMR → look → fix: verify your own work.
|
|
118
|
+
|
|
119
|
+
Dev writes go to a sandboxed `#dev` namespace inside your real workspace —
|
|
120
|
+
iterate freely, live app data is untouched. The "DEV · … · sandbox data" badge
|
|
121
|
+
confirms it.
|
|
122
|
+
|
|
123
|
+
## Modeling tips
|
|
124
|
+
|
|
125
|
+
- Rows, not embedded arrays: a comments thread is a `comments` table with a
|
|
126
|
+
`parentId` field, not an array inside a record (records cap at 1 MiB).
|
|
127
|
+
- People are `z.string()` userIds from `useMembers()`; render names/avatars
|
|
128
|
+
from the member list.
|
|
129
|
+
- Timestamps are `z.number()` epochs or `z.iso.datetime()` strings — never
|
|
130
|
+
`z.date()`.
|
|
131
|
+
- Field names `_*`, `updatedAt`, `createdBy` are reserved for system fields.
|
|
132
|
+
- Cross-field `.refine()` rules run on **insert only** — updates validate
|
|
133
|
+
field-by-field. Keep invariants per-field where possible.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "radix-lyra",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/index.css",
|
|
9
|
+
"baseColor": "olive",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "lucide",
|
|
14
|
+
"rtl": false,
|
|
15
|
+
"menuColor": "default",
|
|
16
|
+
"menuAccent": "subtle",
|
|
17
|
+
"aliases": {
|
|
18
|
+
"components": "@/components",
|
|
19
|
+
"utils": "@/lib/utils",
|
|
20
|
+
"ui": "@/components/ui",
|
|
21
|
+
"lib": "@/lib",
|
|
22
|
+
"hooks": "@/hooks"
|
|
23
|
+
},
|
|
24
|
+
"registries": {
|
|
25
|
+
"@kibo-ui": "https://www.kibo-ui.com/r/{name}.json",
|
|
26
|
+
"@diceui": "https://diceui.com/r/{name}.json"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Expenses</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|