uniquick 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/README.md +75 -0
- package/dist/cli.js +241 -0
- package/dist/mcp.js +347 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# uniquick
|
|
2
|
+
|
|
3
|
+
CLI + MCP server for **UniQuick** — deploy AI-built web apps (guestbooks, polls,
|
|
4
|
+
dashboards, live multiplayer pages, AI tools) to the University of Illinois
|
|
5
|
+
UniQuick platform. Visitors sign in silently with their `@illinois.edu` account;
|
|
6
|
+
data, file storage, realtime, and AI are provided by the platform.
|
|
7
|
+
|
|
8
|
+
Live at **https://uniquick.azurewebsites.net**.
|
|
9
|
+
|
|
10
|
+
## 1. Get a deploy token (once)
|
|
11
|
+
|
|
12
|
+
Open **https://uniquick.azurewebsites.net/token**, sign in with your
|
|
13
|
+
`@illinois.edu` account, create a token, and copy it (shown once, looks like
|
|
14
|
+
`qk_...`). Treat it like a password; revoke it from the same page any time.
|
|
15
|
+
|
|
16
|
+
## 2a. Use it as an MCP server (recommended for Claude Code / Desktop)
|
|
17
|
+
|
|
18
|
+
No clone, no install — one command:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
claude mcp add uniquick --env UNIQUICK_TOKEN=qk_... -- npx -y -p uniquick uniquick-mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Your agent gains the tools `create_site`, `deploy_site`, `list_sites`,
|
|
25
|
+
`delete_site`, `get_site_url`. Then just ask:
|
|
26
|
+
|
|
27
|
+
> Create a UniQuick site called `team-poll` and deploy a ranked-choice voting page to it.
|
|
28
|
+
|
|
29
|
+
By default `deploy_site` only deploys directories inside the MCP server's working
|
|
30
|
+
directory (the exfiltration guard). Set `UNIQUICK_DEPLOY_ROOT` to widen it:
|
|
31
|
+
`--env UNIQUICK_DEPLOY_ROOT=/path/to/projects`.
|
|
32
|
+
|
|
33
|
+
## 2b. Or use the CLI directly
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export UNIQUICK_TOKEN=qk_...
|
|
37
|
+
npx -y uniquick token-check # verify auth
|
|
38
|
+
npx -y uniquick create my-site --title "My Site"
|
|
39
|
+
npx -y uniquick deploy ./my-site-dir --site my-site
|
|
40
|
+
npx -y uniquick list
|
|
41
|
+
npx -y uniquick delete <slug> --yes
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Site names are auto-prefixed with your netid (`my-site` → `netid-my-site`); the
|
|
45
|
+
create output shows the final id. Your site is served at
|
|
46
|
+
`https://uniquick.azurewebsites.net/s/<slug>/`.
|
|
47
|
+
|
|
48
|
+
## Building a site
|
|
49
|
+
|
|
50
|
+
Make a folder with an `index.html` that loads the SDK:
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<script src="https://uniquick.azurewebsites.net/sdk.js"></script>
|
|
54
|
+
<script type="module">
|
|
55
|
+
await quick.ready; // silent SSO done
|
|
56
|
+
quick.user; // { name, upn }
|
|
57
|
+
await quick.data.set("k", 42); // per-site JSON store
|
|
58
|
+
const url = await quick.files.upload(file); // hosted file
|
|
59
|
+
quick.ws.send("chat", "hi"); // realtime
|
|
60
|
+
const a = await quick.ai.chat("Summarize: " + text); // built-in AI, no key
|
|
61
|
+
</script>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Full SDK reference (written for coding agents): paste
|
|
65
|
+
**https://uniquick.azurewebsites.net/llms.txt** into your agent's context.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Env | Default | Purpose |
|
|
70
|
+
|-----|---------|---------|
|
|
71
|
+
| `UNIQUICK_TOKEN` | — | Deploy token from `/token` (required) |
|
|
72
|
+
| `UNIQUICK_URL` | `https://uniquick.azurewebsites.net` | Platform base URL |
|
|
73
|
+
| `UNIQUICK_DEPLOY_ROOT` | cwd | Directories the MCP `deploy_site` tool may read from |
|
|
74
|
+
|
|
75
|
+
Questions → vishal@illinois.edu.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../cli/uniquick.ts
|
|
4
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from "node:fs";
|
|
5
|
+
import { join, relative, extname, resolve, sep } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
var MIME = {
|
|
8
|
+
".html": "text/html",
|
|
9
|
+
".htm": "text/html",
|
|
10
|
+
".js": "text/javascript",
|
|
11
|
+
".mjs": "text/javascript",
|
|
12
|
+
".css": "text/css",
|
|
13
|
+
".json": "application/json",
|
|
14
|
+
".txt": "text/plain",
|
|
15
|
+
".md": "text/markdown",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".svg": "image/svg+xml",
|
|
21
|
+
".ico": "image/x-icon",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
".woff": "font/woff",
|
|
24
|
+
".woff2": "font/woff2",
|
|
25
|
+
".mp3": "audio/mpeg",
|
|
26
|
+
".mp4": "video/mp4",
|
|
27
|
+
".webm": "video/webm",
|
|
28
|
+
".wasm": "application/wasm",
|
|
29
|
+
".pdf": "application/pdf",
|
|
30
|
+
".xml": "application/xml"
|
|
31
|
+
};
|
|
32
|
+
function mimeFor(path) {
|
|
33
|
+
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
34
|
+
}
|
|
35
|
+
var MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
36
|
+
var MAX_TOTAL_BYTES = 40 * 1024 * 1024;
|
|
37
|
+
function buildManifest(dir, options = {}) {
|
|
38
|
+
const includeDotfiles = options.includeDotfiles ?? false;
|
|
39
|
+
const files = [];
|
|
40
|
+
let totalBytes = 0;
|
|
41
|
+
const walk = (current) => {
|
|
42
|
+
const entries = readdirSync(current, { withFileTypes: true }).sort(
|
|
43
|
+
(a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
44
|
+
);
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const name = entry.name;
|
|
47
|
+
if (name === ".git" || name === "node_modules" || name === ".DS_Store") continue;
|
|
48
|
+
if (!includeDotfiles && name.startsWith(".")) continue;
|
|
49
|
+
if (entry.isSymbolicLink()) continue;
|
|
50
|
+
const full = join(current, name);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
walk(full);
|
|
53
|
+
} else if (entry.isFile()) {
|
|
54
|
+
const rel = relative(dir, full).split(sep).join("/");
|
|
55
|
+
const size = statSync(full).size;
|
|
56
|
+
if (size > MAX_FILE_BYTES) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`file too large: ${rel} is ${(size / 1024 / 1024).toFixed(1)}MB (limit 5MB per file)`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
totalBytes += size;
|
|
62
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`deploy too large: total exceeds 40MB at ${rel} (${(totalBytes / 1024 / 1024).toFixed(1)}MB so far)`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
files.push({
|
|
68
|
+
path: rel,
|
|
69
|
+
contentBase64: readFileSync(full).toString("base64"),
|
|
70
|
+
contentType: mimeFor(rel)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(dir);
|
|
76
|
+
return { files };
|
|
77
|
+
}
|
|
78
|
+
function checkDeployRoot(dir, root) {
|
|
79
|
+
const realRoot = realpathSync(resolve(root));
|
|
80
|
+
const realDir = realpathSync(resolve(dir));
|
|
81
|
+
if (realDir !== realRoot && !realDir.startsWith(realRoot + sep)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`refusing to deploy '${dir}': it is outside the deploy root '${realRoot}'. Set UNIQUICK_DEPLOY_ROOT to a parent directory of your site files to allow it.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return realDir;
|
|
87
|
+
}
|
|
88
|
+
function parseArgs(argv) {
|
|
89
|
+
const [command = "", ...rest] = argv;
|
|
90
|
+
const positional = [];
|
|
91
|
+
const flags = {};
|
|
92
|
+
for (let i = 0; i < rest.length; i++) {
|
|
93
|
+
const arg = rest[i];
|
|
94
|
+
if (arg.startsWith("--")) {
|
|
95
|
+
const name = arg.slice(2);
|
|
96
|
+
const next = rest[i + 1];
|
|
97
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
98
|
+
flags[name] = next;
|
|
99
|
+
i++;
|
|
100
|
+
} else {
|
|
101
|
+
flags[name] = true;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
positional.push(arg);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { command, positional, flags };
|
|
108
|
+
}
|
|
109
|
+
var BASE = (process.env.UNIQUICK_URL ?? "https://uniquick.azurewebsites.net").replace(/\/+$/, "");
|
|
110
|
+
function fail(msg) {
|
|
111
|
+
console.error(`error: ${msg}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
function token() {
|
|
115
|
+
const t = process.env.UNIQUICK_TOKEN;
|
|
116
|
+
if (!t) {
|
|
117
|
+
fail(`UNIQUICK_TOKEN is not set.
|
|
118
|
+
1. Open ${BASE}/token in a browser and sign in with your @illinois.edu account
|
|
119
|
+
2. Create a token and run: export UNIQUICK_TOKEN=qk_...`);
|
|
120
|
+
}
|
|
121
|
+
return t;
|
|
122
|
+
}
|
|
123
|
+
async function api(method, path, body) {
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await fetch(BASE + path, {
|
|
127
|
+
method,
|
|
128
|
+
headers: {
|
|
129
|
+
authorization: `Bearer ${token()}`,
|
|
130
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
131
|
+
},
|
|
132
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
133
|
+
});
|
|
134
|
+
} catch (e) {
|
|
135
|
+
fail(`could not reach ${BASE} (${e?.message ?? e}). Is UNIQUICK_URL right?`);
|
|
136
|
+
}
|
|
137
|
+
const text = await res.text();
|
|
138
|
+
let parsed = null;
|
|
139
|
+
try {
|
|
140
|
+
parsed = text ? JSON.parse(text) : null;
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
const detail = parsed?.error?.message ?? text.slice(0, 200) ?? res.statusText;
|
|
145
|
+
if (res.status === 401) {
|
|
146
|
+
fail(`authentication failed (401): ${detail}
|
|
147
|
+
Your UNIQUICK_TOKEN may be revoked \u2014 get a fresh one at ${BASE}/token`);
|
|
148
|
+
}
|
|
149
|
+
fail(`${method} ${path} failed (${res.status}): ${detail}`);
|
|
150
|
+
}
|
|
151
|
+
return parsed;
|
|
152
|
+
}
|
|
153
|
+
var USAGE = `uniquick \u2014 deploy AI-built sites to ${BASE}
|
|
154
|
+
|
|
155
|
+
usage:
|
|
156
|
+
uniquick create <slug> --title <title> [--data-mode open|owner-write]
|
|
157
|
+
uniquick deploy <dir> --site <slug>
|
|
158
|
+
uniquick list
|
|
159
|
+
uniquick delete <slug> --yes
|
|
160
|
+
uniquick token-check
|
|
161
|
+
|
|
162
|
+
(from a repo checkout, prefix any command with: npx tsx cli/uniquick.ts)
|
|
163
|
+
|
|
164
|
+
env:
|
|
165
|
+
UNIQUICK_URL platform base URL (default https://uniquick.azurewebsites.net)
|
|
166
|
+
UNIQUICK_TOKEN deploy token from ${BASE}/token`;
|
|
167
|
+
async function main() {
|
|
168
|
+
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
|
169
|
+
switch (command) {
|
|
170
|
+
case "create": {
|
|
171
|
+
const slug = positional[0] ?? fail("usage: uniquick create <slug> --title <title> [--data-mode open|owner-write]");
|
|
172
|
+
const title = typeof flags.title === "string" ? flags.title : fail("--title <title> is required");
|
|
173
|
+
const dataMode = flags["data-mode"];
|
|
174
|
+
if (dataMode !== void 0 && dataMode !== "open" && dataMode !== "owner-write") {
|
|
175
|
+
fail("--data-mode must be 'open' or 'owner-write'");
|
|
176
|
+
}
|
|
177
|
+
const body = { slug, title };
|
|
178
|
+
if (typeof dataMode === "string") body.data_mode = dataMode;
|
|
179
|
+
const created = await api("POST", "/api/sites", body);
|
|
180
|
+
console.log(`Created site '${created.id}' -> ${BASE}/s/${created.id}/`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "deploy": {
|
|
184
|
+
const dirArg = positional[0] ?? fail("usage: uniquick deploy <dir> --site <slug>");
|
|
185
|
+
const site = typeof flags.site === "string" ? flags.site : fail("--site <slug> is required");
|
|
186
|
+
const dir = resolve(dirArg);
|
|
187
|
+
if (!existsSync(dir)) fail(`directory not found: ${dir}`);
|
|
188
|
+
const manifest = buildManifest(dir);
|
|
189
|
+
if (manifest.files.length === 0) fail(`no files found in ${dir}`);
|
|
190
|
+
if (!manifest.files.some((f) => f.path === "index.html")) {
|
|
191
|
+
console.error(`warning: no top-level index.html \u2014 ${BASE}/s/${site}/ will 404`);
|
|
192
|
+
}
|
|
193
|
+
await api("POST", `/api/sites/${encodeURIComponent(site)}/deploy`, manifest);
|
|
194
|
+
console.log(`Deployed ${manifest.files.length} file(s) -> ${BASE}/s/${site}/`);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "list": {
|
|
198
|
+
const sites = await api("GET", "/api/sites");
|
|
199
|
+
if (!Array.isArray(sites) || sites.length === 0) {
|
|
200
|
+
console.log("no sites yet \u2014 try: npx tsx cli/uniquick.ts create my-site --title 'My Site'");
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
for (const s of sites) {
|
|
204
|
+
console.log(`${s.id} ${s.data_mode} ${BASE}/s/${s.id}/ ${s.title}`);
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case "delete": {
|
|
209
|
+
const slug = positional[0] ?? fail("usage: uniquick delete <slug> --yes");
|
|
210
|
+
if (flags.yes !== true) fail(`refusing to delete '${slug}' without --yes (this removes the site, its data, and its uploads)`);
|
|
211
|
+
await api("DELETE", `/api/sites/${encodeURIComponent(slug)}`);
|
|
212
|
+
console.log(`Deleted site '${slug}'`);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "token-check": {
|
|
216
|
+
const me = await api("GET", "/api/me");
|
|
217
|
+
console.log(`Token OK \u2014 authenticated as ${me.name} <${me.upn}> against ${BASE}`);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "":
|
|
221
|
+
console.log(USAGE);
|
|
222
|
+
break;
|
|
223
|
+
default:
|
|
224
|
+
console.error(`unknown command '${command}'
|
|
225
|
+
|
|
226
|
+
${USAGE}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
var isMain = !!process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
231
|
+
if (isMain) {
|
|
232
|
+
main().catch((e) => fail(e?.message ?? String(e)));
|
|
233
|
+
}
|
|
234
|
+
export {
|
|
235
|
+
MAX_FILE_BYTES,
|
|
236
|
+
MAX_TOTAL_BYTES,
|
|
237
|
+
buildManifest,
|
|
238
|
+
checkDeployRoot,
|
|
239
|
+
mimeFor,
|
|
240
|
+
parseArgs
|
|
241
|
+
};
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../mcp/server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { resolve as resolve2 } from "node:path";
|
|
8
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
9
|
+
|
|
10
|
+
// ../cli/uniquick.ts
|
|
11
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from "node:fs";
|
|
12
|
+
import { join, relative, extname, resolve, sep } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
var MIME = {
|
|
15
|
+
".html": "text/html",
|
|
16
|
+
".htm": "text/html",
|
|
17
|
+
".js": "text/javascript",
|
|
18
|
+
".mjs": "text/javascript",
|
|
19
|
+
".css": "text/css",
|
|
20
|
+
".json": "application/json",
|
|
21
|
+
".txt": "text/plain",
|
|
22
|
+
".md": "text/markdown",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".gif": "image/gif",
|
|
27
|
+
".svg": "image/svg+xml",
|
|
28
|
+
".ico": "image/x-icon",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
".woff": "font/woff",
|
|
31
|
+
".woff2": "font/woff2",
|
|
32
|
+
".mp3": "audio/mpeg",
|
|
33
|
+
".mp4": "video/mp4",
|
|
34
|
+
".webm": "video/webm",
|
|
35
|
+
".wasm": "application/wasm",
|
|
36
|
+
".pdf": "application/pdf",
|
|
37
|
+
".xml": "application/xml"
|
|
38
|
+
};
|
|
39
|
+
function mimeFor(path) {
|
|
40
|
+
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
41
|
+
}
|
|
42
|
+
var MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
43
|
+
var MAX_TOTAL_BYTES = 40 * 1024 * 1024;
|
|
44
|
+
function buildManifest(dir, options = {}) {
|
|
45
|
+
const includeDotfiles = options.includeDotfiles ?? false;
|
|
46
|
+
const files = [];
|
|
47
|
+
let totalBytes = 0;
|
|
48
|
+
const walk = (current) => {
|
|
49
|
+
const entries = readdirSync(current, { withFileTypes: true }).sort(
|
|
50
|
+
(a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
51
|
+
);
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const name = entry.name;
|
|
54
|
+
if (name === ".git" || name === "node_modules" || name === ".DS_Store") continue;
|
|
55
|
+
if (!includeDotfiles && name.startsWith(".")) continue;
|
|
56
|
+
if (entry.isSymbolicLink()) continue;
|
|
57
|
+
const full = join(current, name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
walk(full);
|
|
60
|
+
} else if (entry.isFile()) {
|
|
61
|
+
const rel = relative(dir, full).split(sep).join("/");
|
|
62
|
+
const size = statSync(full).size;
|
|
63
|
+
if (size > MAX_FILE_BYTES) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`file too large: ${rel} is ${(size / 1024 / 1024).toFixed(1)}MB (limit 5MB per file)`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
totalBytes += size;
|
|
69
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`deploy too large: total exceeds 40MB at ${rel} (${(totalBytes / 1024 / 1024).toFixed(1)}MB so far)`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
files.push({
|
|
75
|
+
path: rel,
|
|
76
|
+
contentBase64: readFileSync(full).toString("base64"),
|
|
77
|
+
contentType: mimeFor(rel)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(dir);
|
|
83
|
+
return { files };
|
|
84
|
+
}
|
|
85
|
+
function checkDeployRoot(dir, root) {
|
|
86
|
+
const realRoot = realpathSync(resolve(root));
|
|
87
|
+
const realDir = realpathSync(resolve(dir));
|
|
88
|
+
if (realDir !== realRoot && !realDir.startsWith(realRoot + sep)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`refusing to deploy '${dir}': it is outside the deploy root '${realRoot}'. Set UNIQUICK_DEPLOY_ROOT to a parent directory of your site files to allow it.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return realDir;
|
|
94
|
+
}
|
|
95
|
+
function parseArgs(argv) {
|
|
96
|
+
const [command = "", ...rest] = argv;
|
|
97
|
+
const positional = [];
|
|
98
|
+
const flags = {};
|
|
99
|
+
for (let i = 0; i < rest.length; i++) {
|
|
100
|
+
const arg = rest[i];
|
|
101
|
+
if (arg.startsWith("--")) {
|
|
102
|
+
const name = arg.slice(2);
|
|
103
|
+
const next = rest[i + 1];
|
|
104
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
105
|
+
flags[name] = next;
|
|
106
|
+
i++;
|
|
107
|
+
} else {
|
|
108
|
+
flags[name] = true;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
positional.push(arg);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { command, positional, flags };
|
|
115
|
+
}
|
|
116
|
+
var BASE = (process.env.UNIQUICK_URL ?? "https://uniquick.azurewebsites.net").replace(/\/+$/, "");
|
|
117
|
+
function fail(msg) {
|
|
118
|
+
console.error(`error: ${msg}`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
function token() {
|
|
122
|
+
const t = process.env.UNIQUICK_TOKEN;
|
|
123
|
+
if (!t) {
|
|
124
|
+
fail(`UNIQUICK_TOKEN is not set.
|
|
125
|
+
1. Open ${BASE}/token in a browser and sign in with your @illinois.edu account
|
|
126
|
+
2. Create a token and run: export UNIQUICK_TOKEN=qk_...`);
|
|
127
|
+
}
|
|
128
|
+
return t;
|
|
129
|
+
}
|
|
130
|
+
async function api(method, path, body) {
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
res = await fetch(BASE + path, {
|
|
134
|
+
method,
|
|
135
|
+
headers: {
|
|
136
|
+
authorization: `Bearer ${token()}`,
|
|
137
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
138
|
+
},
|
|
139
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
140
|
+
});
|
|
141
|
+
} catch (e) {
|
|
142
|
+
fail(`could not reach ${BASE} (${e?.message ?? e}). Is UNIQUICK_URL right?`);
|
|
143
|
+
}
|
|
144
|
+
const text = await res.text();
|
|
145
|
+
let parsed = null;
|
|
146
|
+
try {
|
|
147
|
+
parsed = text ? JSON.parse(text) : null;
|
|
148
|
+
} catch {
|
|
149
|
+
}
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
const detail = parsed?.error?.message ?? text.slice(0, 200) ?? res.statusText;
|
|
152
|
+
if (res.status === 401) {
|
|
153
|
+
fail(`authentication failed (401): ${detail}
|
|
154
|
+
Your UNIQUICK_TOKEN may be revoked \u2014 get a fresh one at ${BASE}/token`);
|
|
155
|
+
}
|
|
156
|
+
fail(`${method} ${path} failed (${res.status}): ${detail}`);
|
|
157
|
+
}
|
|
158
|
+
return parsed;
|
|
159
|
+
}
|
|
160
|
+
var USAGE = `uniquick \u2014 deploy AI-built sites to ${BASE}
|
|
161
|
+
|
|
162
|
+
usage:
|
|
163
|
+
uniquick create <slug> --title <title> [--data-mode open|owner-write]
|
|
164
|
+
uniquick deploy <dir> --site <slug>
|
|
165
|
+
uniquick list
|
|
166
|
+
uniquick delete <slug> --yes
|
|
167
|
+
uniquick token-check
|
|
168
|
+
|
|
169
|
+
(from a repo checkout, prefix any command with: npx tsx cli/uniquick.ts)
|
|
170
|
+
|
|
171
|
+
env:
|
|
172
|
+
UNIQUICK_URL platform base URL (default https://uniquick.azurewebsites.net)
|
|
173
|
+
UNIQUICK_TOKEN deploy token from ${BASE}/token`;
|
|
174
|
+
async function main() {
|
|
175
|
+
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
|
176
|
+
switch (command) {
|
|
177
|
+
case "create": {
|
|
178
|
+
const slug = positional[0] ?? fail("usage: uniquick create <slug> --title <title> [--data-mode open|owner-write]");
|
|
179
|
+
const title = typeof flags.title === "string" ? flags.title : fail("--title <title> is required");
|
|
180
|
+
const dataMode = flags["data-mode"];
|
|
181
|
+
if (dataMode !== void 0 && dataMode !== "open" && dataMode !== "owner-write") {
|
|
182
|
+
fail("--data-mode must be 'open' or 'owner-write'");
|
|
183
|
+
}
|
|
184
|
+
const body = { slug, title };
|
|
185
|
+
if (typeof dataMode === "string") body.data_mode = dataMode;
|
|
186
|
+
const created = await api("POST", "/api/sites", body);
|
|
187
|
+
console.log(`Created site '${created.id}' -> ${BASE}/s/${created.id}/`);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "deploy": {
|
|
191
|
+
const dirArg = positional[0] ?? fail("usage: uniquick deploy <dir> --site <slug>");
|
|
192
|
+
const site = typeof flags.site === "string" ? flags.site : fail("--site <slug> is required");
|
|
193
|
+
const dir = resolve(dirArg);
|
|
194
|
+
if (!existsSync(dir)) fail(`directory not found: ${dir}`);
|
|
195
|
+
const manifest = buildManifest(dir);
|
|
196
|
+
if (manifest.files.length === 0) fail(`no files found in ${dir}`);
|
|
197
|
+
if (!manifest.files.some((f) => f.path === "index.html")) {
|
|
198
|
+
console.error(`warning: no top-level index.html \u2014 ${BASE}/s/${site}/ will 404`);
|
|
199
|
+
}
|
|
200
|
+
await api("POST", `/api/sites/${encodeURIComponent(site)}/deploy`, manifest);
|
|
201
|
+
console.log(`Deployed ${manifest.files.length} file(s) -> ${BASE}/s/${site}/`);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "list": {
|
|
205
|
+
const sites = await api("GET", "/api/sites");
|
|
206
|
+
if (!Array.isArray(sites) || sites.length === 0) {
|
|
207
|
+
console.log("no sites yet \u2014 try: npx tsx cli/uniquick.ts create my-site --title 'My Site'");
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
for (const s of sites) {
|
|
211
|
+
console.log(`${s.id} ${s.data_mode} ${BASE}/s/${s.id}/ ${s.title}`);
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "delete": {
|
|
216
|
+
const slug = positional[0] ?? fail("usage: uniquick delete <slug> --yes");
|
|
217
|
+
if (flags.yes !== true) fail(`refusing to delete '${slug}' without --yes (this removes the site, its data, and its uploads)`);
|
|
218
|
+
await api("DELETE", `/api/sites/${encodeURIComponent(slug)}`);
|
|
219
|
+
console.log(`Deleted site '${slug}'`);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case "token-check": {
|
|
223
|
+
const me = await api("GET", "/api/me");
|
|
224
|
+
console.log(`Token OK \u2014 authenticated as ${me.name} <${me.upn}> against ${BASE}`);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "":
|
|
228
|
+
console.log(USAGE);
|
|
229
|
+
break;
|
|
230
|
+
default:
|
|
231
|
+
console.error(`unknown command '${command}'
|
|
232
|
+
|
|
233
|
+
${USAGE}`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
var isMain = !!process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
238
|
+
if (isMain) {
|
|
239
|
+
main().catch((e) => fail(e?.message ?? String(e)));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ../mcp/server.ts
|
|
243
|
+
var BASE2 = (process.env.UNIQUICK_URL ?? "https://uniquick.azurewebsites.net").replace(/\/+$/, "");
|
|
244
|
+
var TOKEN = process.env.UNIQUICK_TOKEN ?? "";
|
|
245
|
+
var DEPLOY_ROOT = resolve2(process.env.UNIQUICK_DEPLOY_ROOT ?? process.cwd());
|
|
246
|
+
var slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}$/, "slug must match ^[a-z0-9][a-z0-9-]{1,62}$");
|
|
247
|
+
async function api2(method, path, body) {
|
|
248
|
+
if (!TOKEN) {
|
|
249
|
+
throw new Error(`UNIQUICK_TOKEN is not set. Get one at ${BASE2}/token and pass it via --env when adding this MCP server.`);
|
|
250
|
+
}
|
|
251
|
+
const res = await fetch(BASE2 + path, {
|
|
252
|
+
method,
|
|
253
|
+
headers: {
|
|
254
|
+
authorization: `Bearer ${TOKEN}`,
|
|
255
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
256
|
+
},
|
|
257
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
258
|
+
});
|
|
259
|
+
const text = await res.text();
|
|
260
|
+
let parsed = null;
|
|
261
|
+
try {
|
|
262
|
+
parsed = text ? JSON.parse(text) : null;
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
const detail = parsed?.error?.message ?? text.slice(0, 200);
|
|
267
|
+
throw new Error(`${method} ${path} failed (${res.status}): ${detail}`);
|
|
268
|
+
}
|
|
269
|
+
return parsed;
|
|
270
|
+
}
|
|
271
|
+
function ok(text) {
|
|
272
|
+
return { content: [{ type: "text", text }] };
|
|
273
|
+
}
|
|
274
|
+
var server = new McpServer({ name: "uniquick", version: "1.0.0" });
|
|
275
|
+
server.registerTool(
|
|
276
|
+
"create_site",
|
|
277
|
+
{
|
|
278
|
+
description: `Create a new UniQuick site. slug becomes the URL: ${BASE2}/s/<slug>/. data_mode 'open' (default) lets any signed-in visitor write data; 'owner-write' restricts data writes to the site owner.`,
|
|
279
|
+
inputSchema: {
|
|
280
|
+
slug: slugSchema.describe(
|
|
281
|
+
"URL slug, lowercase letters/digits/hyphens, e.g. 'guestbook'. The server prefixes it with your netid ('guestbook' becomes 'vishal-guestbook') \u2014 use the id from the response for later calls."
|
|
282
|
+
),
|
|
283
|
+
title: z.string().describe("Human-readable site title"),
|
|
284
|
+
data_mode: z.enum(["open", "owner-write"]).optional()
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
async ({ slug, title, data_mode }) => {
|
|
288
|
+
const body = { slug, title };
|
|
289
|
+
if (data_mode) body.data_mode = data_mode;
|
|
290
|
+
const created = await api2("POST", "/api/sites", body);
|
|
291
|
+
return ok(`Created site '${created.id}'. Deploy files with deploy_site (site: '${created.id}'), then visit ${BASE2}/s/${created.id}/`);
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
server.registerTool(
|
|
295
|
+
"deploy_site",
|
|
296
|
+
{
|
|
297
|
+
description: "Deploy a local directory of static files (HTML/JS/CSS/images) to an existing UniQuick site. Walks the directory recursively and uploads every file (dotfiles, symlinks, .git, and node_modules are skipped; 5MB per-file / 40MB total limits). SECURITY: dir MUST be inside the configured deploy root (UNIQUICK_DEPLOY_ROOT, default: the server's working directory) \u2014 deploys from other locations (home directories, system paths, secret stores) are refused. Never deploy directories the user did not explicitly ask to publish.",
|
|
298
|
+
inputSchema: {
|
|
299
|
+
site: slugSchema.describe("Site slug to deploy to"),
|
|
300
|
+
dir: z.string().describe("Absolute path to the directory containing index.html (must be inside UNIQUICK_DEPLOY_ROOT)")
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
async ({ site, dir }) => {
|
|
304
|
+
const abs = resolve2(dir);
|
|
305
|
+
if (!existsSync2(abs)) throw new Error(`directory not found: ${abs}`);
|
|
306
|
+
const real = checkDeployRoot(abs, DEPLOY_ROOT);
|
|
307
|
+
const manifest = buildManifest(real);
|
|
308
|
+
if (manifest.files.length === 0) throw new Error(`no files found in ${real}`);
|
|
309
|
+
const warn = manifest.files.some((f) => f.path === "index.html") ? "" : "\nWarning: no top-level index.html \u2014 the site root will 404.";
|
|
310
|
+
await api2("POST", `/api/sites/${encodeURIComponent(site)}/deploy`, manifest);
|
|
311
|
+
return ok(`Deployed ${manifest.files.length} file(s) to ${BASE2}/s/${site}/${warn}`);
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
server.registerTool(
|
|
315
|
+
"list_sites",
|
|
316
|
+
{
|
|
317
|
+
description: "List the sites owned by the current deploy token, with URLs.",
|
|
318
|
+
inputSchema: {}
|
|
319
|
+
},
|
|
320
|
+
async () => {
|
|
321
|
+
const sites = await api2("GET", "/api/sites");
|
|
322
|
+
if (!Array.isArray(sites) || sites.length === 0) return ok("No sites yet. Use create_site first.");
|
|
323
|
+
const lines = sites.map((s) => `- ${s.id} (${s.data_mode}) "${s.title}" -> ${BASE2}/s/${s.id}/`);
|
|
324
|
+
return ok(lines.join("\n"));
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
server.registerTool(
|
|
328
|
+
"delete_site",
|
|
329
|
+
{
|
|
330
|
+
description: "Permanently delete a site, including its deployed files, KV data, and uploads. Irreversible.",
|
|
331
|
+
inputSchema: { slug: slugSchema.describe("Site slug to delete") }
|
|
332
|
+
},
|
|
333
|
+
async ({ slug }) => {
|
|
334
|
+
await api2("DELETE", `/api/sites/${encodeURIComponent(slug)}`);
|
|
335
|
+
return ok(`Deleted site '${slug}'.`);
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
server.registerTool(
|
|
339
|
+
"get_site_url",
|
|
340
|
+
{
|
|
341
|
+
description: "Return the public URL for a site slug.",
|
|
342
|
+
inputSchema: { slug: slugSchema }
|
|
343
|
+
},
|
|
344
|
+
async ({ slug }) => ok(`${BASE2}/s/${slug}/`)
|
|
345
|
+
);
|
|
346
|
+
var transport = new StdioServerTransport();
|
|
347
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uniquick",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + MCP server for UniQuick — deploy AI-built sites to the UIUC UniQuick platform with one prompt.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"uniquick": "dist/cli.js",
|
|
8
|
+
"uniquick-mcp": "dist/mcp.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node build.mjs",
|
|
19
|
+
"prepack": "node build.mjs"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
23
|
+
"zod": "^3.24.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"esbuild": "^0.25.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"uniquick",
|
|
30
|
+
"mcp",
|
|
31
|
+
"model-context-protocol",
|
|
32
|
+
"illinois",
|
|
33
|
+
"deploy",
|
|
34
|
+
"static-sites"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/gies-ai-experiments/uniquick.git",
|
|
40
|
+
"directory": "npm"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://uniquick.azurewebsites.net"
|
|
43
|
+
}
|