@montytools/cli 0.1.0 → 0.1.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 +162 -5
- package/package.json +1 -1
- package/template/AGENTS.md +2 -0
package/bin/monty.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// deterministic final line (`deployed: …` / `error: …`).
|
|
5
5
|
|
|
6
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
7
8
|
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
9
|
+
import { createServer } from "node:http";
|
|
8
10
|
import { homedir } from "node:os";
|
|
9
11
|
import { basename, dirname, join, relative } from "node:path";
|
|
10
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -14,6 +16,9 @@ import { CATALOG, REGISTRIES } from "./catalog.mjs";
|
|
|
14
16
|
const CONFIG_DIR = join(homedir(), ".monty");
|
|
15
17
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
16
18
|
const DEFAULT_HOST = "https://usemonty.dev";
|
|
19
|
+
// Every app's source lives in one predictable place. `monty create` stamps
|
|
20
|
+
// here by default (override with --dir) and `monty login` provisions it.
|
|
21
|
+
const MONTY_HOME = join(homedir(), "Monty");
|
|
17
22
|
|
|
18
23
|
const [, , command, ...rest] = process.argv;
|
|
19
24
|
|
|
@@ -39,6 +44,11 @@ function loadConfig() {
|
|
|
39
44
|
async function login() {
|
|
40
45
|
const host = flag("host") ?? DEFAULT_HOST;
|
|
41
46
|
let key = flag("key");
|
|
47
|
+
if (!key) {
|
|
48
|
+
// Browser flow: loopback callback + explicit Authorize click in the host.
|
|
49
|
+
// Falls back to paste when the browser can't be opened (SSH, CI).
|
|
50
|
+
key = await browserLogin(host).catch(() => null);
|
|
51
|
+
}
|
|
42
52
|
if (!key) {
|
|
43
53
|
console.log(`open: ${host}/cli-auth (sign in, create a CLI key)`);
|
|
44
54
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -50,7 +60,138 @@ async function login() {
|
|
|
50
60
|
}
|
|
51
61
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
52
62
|
writeFileSync(CONFIG_PATH, JSON.stringify({ host, key }, null, 2) + "\n");
|
|
63
|
+
mkdirSync(MONTY_HOME, { recursive: true });
|
|
53
64
|
console.log(`logged-in: ${host} (key saved to ~/.monty/config.json)`);
|
|
65
|
+
console.log(`apps home: ${MONTY_HOME}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Loopback auth: serve one callback on 127.0.0.1, send the browser to
|
|
69
|
+
// /cli-auth with our address + a state nonce, and wait for the host to
|
|
70
|
+
// redirect back with a freshly minted key. The nonce ties the callback to
|
|
71
|
+
// THIS invocation; the host only ever redirects to 127.0.0.1/localhost.
|
|
72
|
+
function browserLogin(host) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const state = randomBytes(16).toString("hex");
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
server.close();
|
|
77
|
+
console.log("timeout: no browser authorization after 5 minutes — falling back to paste.");
|
|
78
|
+
reject(new Error("LOGIN_TIMEOUT"));
|
|
79
|
+
}, 300_000);
|
|
80
|
+
|
|
81
|
+
const server = createServer((req, res) => {
|
|
82
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
83
|
+
if (url.pathname !== "/callback") {
|
|
84
|
+
res.writeHead(404).end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const got = url.searchParams.get("key");
|
|
88
|
+
if (url.searchParams.get("state") !== state || !got) {
|
|
89
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
90
|
+
res.end("Stale or invalid authorization — run `monty login` again.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
94
|
+
res.end("<title>Monty CLI</title><body style=\"font-family:system-ui;display:grid;place-items:center;height:100vh\"><p><b>Monty CLI authorized.</b> You can close this tab.</p></body>");
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
server.close();
|
|
97
|
+
resolve(got);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
server.on("error", (err) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
reject(err);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
server.listen(0, "127.0.0.1", () => {
|
|
106
|
+
const { port } = server.address();
|
|
107
|
+
const authUrl = `${host}/cli-auth?redirect=${encodeURIComponent(
|
|
108
|
+
`http://127.0.0.1:${port}/callback`,
|
|
109
|
+
)}&state=${state}`;
|
|
110
|
+
console.log(`auth: opening ${host}/cli-auth in your browser (sign in, click Authorize)`);
|
|
111
|
+
console.log(`auth: if nothing opens, visit ${authUrl}`);
|
|
112
|
+
openInBrowser(authUrl);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function openInBrowser(url) {
|
|
118
|
+
const [cmd, args] =
|
|
119
|
+
process.platform === "darwin"
|
|
120
|
+
? ["open", [url]]
|
|
121
|
+
: process.platform === "win32"
|
|
122
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
123
|
+
: ["xdg-open", [url]];
|
|
124
|
+
try {
|
|
125
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
126
|
+
} catch {
|
|
127
|
+
/* URL is already printed; paste flow remains available */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
// ── monty current / select / apps ───────────────────────────────────────────
|
|
133
|
+
// Folder management users never think about: every app lives in ~/Monty,
|
|
134
|
+
// `current` says where you are, `select` prints the folder for cd $(...).
|
|
135
|
+
function findAppRoot(start) {
|
|
136
|
+
let d = start;
|
|
137
|
+
for (;;) {
|
|
138
|
+
if (existsSync(join(d, "monty.config.ts"))) return d;
|
|
139
|
+
const parent = dirname(d);
|
|
140
|
+
if (parent === d) return null;
|
|
141
|
+
d = parent;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readSlugFromConfig(dir) {
|
|
146
|
+
try {
|
|
147
|
+
return /slug:\s*"([^"]+)"/.exec(readFileSync(join(dir, "monty.config.ts"), "utf8"))?.[1] ?? null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function listLocalApps() {
|
|
154
|
+
if (!existsSync(MONTY_HOME)) return [];
|
|
155
|
+
return readdirSync(MONTY_HOME)
|
|
156
|
+
.map((name) => join(MONTY_HOME, name))
|
|
157
|
+
.filter((p) => existsSync(join(p, "monty.config.ts")))
|
|
158
|
+
.map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p) }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function current() {
|
|
162
|
+
const root = findAppRoot(process.cwd());
|
|
163
|
+
if (!root) {
|
|
164
|
+
fail("NOT_IN_APP", `You are not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one.`);
|
|
165
|
+
}
|
|
166
|
+
console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
|
|
167
|
+
console.log(`path: ${root}`);
|
|
168
|
+
if (!root.startsWith(MONTY_HOME)) {
|
|
169
|
+
console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function select() {
|
|
174
|
+
const slug = rest.find((a) => !a.startsWith("--"));
|
|
175
|
+
if (!slug) {
|
|
176
|
+
fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
|
|
177
|
+
}
|
|
178
|
+
const apps = listLocalApps();
|
|
179
|
+
const hit = apps.find((a) => a.slug === slug || basename(a.path) === slug);
|
|
180
|
+
if (!hit) {
|
|
181
|
+
const known = apps.map((a) => a.slug).join(", ") || "(none)";
|
|
182
|
+
fail("APP_NOT_LOCAL", `No local source for "${slug}" in ${MONTY_HOME}. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
|
|
183
|
+
}
|
|
184
|
+
// Bare path on stdout so command substitution works: cd "$(monty select x)"
|
|
185
|
+
console.log(hit.path);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function apps() {
|
|
189
|
+
const local = listLocalApps();
|
|
190
|
+
if (!local.length) {
|
|
191
|
+
console.log(`no local apps in ${MONTY_HOME} — create one with \`monty create <slug>\``);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const a of local) console.log(`${a.slug}\t${a.path}`);
|
|
54
195
|
}
|
|
55
196
|
|
|
56
197
|
// ── monty create ───────────────────────────────────────────────────────────
|
|
@@ -63,10 +204,14 @@ async function create() {
|
|
|
63
204
|
flag("name") ??
|
|
64
205
|
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
65
206
|
const icon = flag("icon") ?? "layout-grid";
|
|
66
|
-
|
|
207
|
+
// Apps live in ~/Monty/<slug> unless --dir points elsewhere.
|
|
208
|
+
const target = flag("dir")
|
|
209
|
+
? join(process.cwd(), flag("dir"))
|
|
210
|
+
: join(MONTY_HOME, slug);
|
|
67
211
|
if (existsSync(target)) {
|
|
68
212
|
fail("DIR_EXISTS", `${target} already exists. Pick another slug or remove the directory.`);
|
|
69
213
|
}
|
|
214
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
70
215
|
|
|
71
216
|
// Template is bundled into the published package (../template). Fall back to
|
|
72
217
|
// the monorepo path when running the CLI in-place during development.
|
|
@@ -121,7 +266,7 @@ async function create() {
|
|
|
121
266
|
}
|
|
122
267
|
|
|
123
268
|
console.log(`created: ${target}`);
|
|
124
|
-
console.log(`next: cd ${
|
|
269
|
+
console.log(`next: cd ${target} && pnpm install && monty dev`);
|
|
125
270
|
}
|
|
126
271
|
|
|
127
272
|
// ── monty dev ──────────────────────────────────────────────────────────────
|
|
@@ -381,17 +526,29 @@ switch (command) {
|
|
|
381
526
|
case "docs":
|
|
382
527
|
await docs();
|
|
383
528
|
break;
|
|
529
|
+
case "current":
|
|
530
|
+
current();
|
|
531
|
+
break;
|
|
532
|
+
case "select":
|
|
533
|
+
select();
|
|
534
|
+
break;
|
|
535
|
+
case "apps":
|
|
536
|
+
apps();
|
|
537
|
+
break;
|
|
384
538
|
case "deploy":
|
|
385
539
|
await deploy();
|
|
386
540
|
break;
|
|
387
541
|
default:
|
|
388
|
-
console.log("usage: monty <login|create|dev|add|components|docs|deploy>");
|
|
389
|
-
console.log(" login --host <url> --key <mk_...>
|
|
390
|
-
console.log(" create <slug> [--name N] [--icon I] stamp a new app
|
|
542
|
+
console.log("usage: monty <login|create|current|select|apps|dev|add|components|docs|deploy>");
|
|
543
|
+
console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
|
|
544
|
+
console.log(" create <slug> [--name N] [--icon I] stamp a new app into ~/Monty/<slug>");
|
|
391
545
|
console.log(" dev [--port 5173] run the app locally (sandboxed data)");
|
|
392
546
|
console.log(" add <name...> install curated UI components (see `monty components`)");
|
|
393
547
|
console.log(" components [query] list the curated component catalog");
|
|
394
548
|
console.log(" docs <name> view a component's source before installing");
|
|
549
|
+
console.log(" current which app folder am I in?");
|
|
550
|
+
console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
|
|
551
|
+
console.log(" apps list local apps in ~/Monty");
|
|
395
552
|
console.log(" deploy build + upload this app");
|
|
396
553
|
process.exit(command ? 1 : 0);
|
|
397
554
|
}
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -4,6 +4,8 @@ Monty is a work OS: your app runs inside a team's workspace, on shared reactive
|
|
|
4
4
|
data, with auth and deployment handled by the platform. **You only write product
|
|
5
5
|
logic.** Everything below is the complete contract.
|
|
6
6
|
|
|
7
|
+
Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current` confirms where you are; `cd "$(monty select <slug>)"` jumps to any app; never create app folders by hand.
|
|
8
|
+
|
|
7
9
|
## The three files that matter
|
|
8
10
|
|
|
9
11
|
| File | What it is |
|