clawnify 0.1.1
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/dist/chunk-6UAOYVZ6.js +325 -0
- package/dist/chunk-6UAOYVZ6.js.map +1 -0
- package/dist/cli-MDRNA6W2.js +741 -0
- package/dist/cli-MDRNA6W2.js.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-JLE4IWRX.js +132 -0
- package/dist/mcp-JLE4IWRX.js.map +1 -0
- package/package.json +27 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
OAUTH_AUTHORIZE_URL,
|
|
4
|
+
OAUTH_CLIENT_ID,
|
|
5
|
+
OAUTH_TOKEN_URL,
|
|
6
|
+
api,
|
|
7
|
+
createArchive,
|
|
8
|
+
generatePkce,
|
|
9
|
+
getEmailFromToken,
|
|
10
|
+
loadAuth,
|
|
11
|
+
pollUntilReady,
|
|
12
|
+
readManifest,
|
|
13
|
+
readProject,
|
|
14
|
+
saveAuth,
|
|
15
|
+
saveProject
|
|
16
|
+
} from "./chunk-6UAOYVZ6.js";
|
|
17
|
+
|
|
18
|
+
// src/cli.ts
|
|
19
|
+
import { Command } from "commander";
|
|
20
|
+
|
|
21
|
+
// src/commands/login.ts
|
|
22
|
+
import crypto from "crypto";
|
|
23
|
+
import http from "http";
|
|
24
|
+
import readline from "readline";
|
|
25
|
+
import open from "open";
|
|
26
|
+
async function loginCommand() {
|
|
27
|
+
const { codeVerifier: verifier, codeChallenge: challenge } = generatePkce();
|
|
28
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
29
|
+
const port = 19823;
|
|
30
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
31
|
+
const authUrl = new URL(OAUTH_AUTHORIZE_URL);
|
|
32
|
+
authUrl.searchParams.set("response_type", "code");
|
|
33
|
+
authUrl.searchParams.set("client_id", OAUTH_CLIENT_ID);
|
|
34
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
35
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
36
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
37
|
+
authUrl.searchParams.set("scope", "openid email");
|
|
38
|
+
authUrl.searchParams.set("state", state);
|
|
39
|
+
const result = await new Promise((resolve, reject) => {
|
|
40
|
+
const timeout = setTimeout(() => {
|
|
41
|
+
server.close();
|
|
42
|
+
reject(new Error("Timed out waiting for authorization."));
|
|
43
|
+
}, 5 * 60 * 1e3);
|
|
44
|
+
const server = http.createServer(async (req, res) => {
|
|
45
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
46
|
+
if (url.pathname !== "/callback") {
|
|
47
|
+
res.writeHead(404);
|
|
48
|
+
res.end("Not found");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const code = url.searchParams.get("code");
|
|
52
|
+
const returnedState = url.searchParams.get("state");
|
|
53
|
+
const error = url.searchParams.get("error");
|
|
54
|
+
if (error) {
|
|
55
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
56
|
+
res.end(
|
|
57
|
+
"<html><body><h2>Authorization denied.</h2><p>You can close this window.</p></body></html>"
|
|
58
|
+
);
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
server.close();
|
|
61
|
+
reject(new Error(`Authorization denied: ${error}`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!code || returnedState !== state) {
|
|
65
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
66
|
+
res.end(
|
|
67
|
+
"<html><body><h2>Invalid callback.</h2></body></html>"
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const tokenRes = await fetch(OAUTH_TOKEN_URL, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
75
|
+
body: new URLSearchParams({
|
|
76
|
+
grant_type: "authorization_code",
|
|
77
|
+
code,
|
|
78
|
+
client_id: OAUTH_CLIENT_ID,
|
|
79
|
+
redirect_uri: redirectUri,
|
|
80
|
+
code_verifier: verifier
|
|
81
|
+
})
|
|
82
|
+
});
|
|
83
|
+
if (!tokenRes.ok) {
|
|
84
|
+
const err = await tokenRes.text();
|
|
85
|
+
throw new Error(`Token exchange failed: ${err}`);
|
|
86
|
+
}
|
|
87
|
+
const tokens = await tokenRes.json();
|
|
88
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
89
|
+
res.end(
|
|
90
|
+
"<html><body><h2>Logged in!</h2><p>You can close this window and return to the terminal.</p></body></html>"
|
|
91
|
+
);
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
server.close();
|
|
94
|
+
resolve(tokens);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
97
|
+
res.end(
|
|
98
|
+
"<html><body><h2>Login failed.</h2></body></html>"
|
|
99
|
+
);
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
server.close();
|
|
102
|
+
reject(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
server.listen(port, () => {
|
|
106
|
+
console.log("Opening browser to authorize...");
|
|
107
|
+
open(authUrl.toString());
|
|
108
|
+
console.log("Waiting for authorization...");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
let email = "unknown";
|
|
112
|
+
try {
|
|
113
|
+
const payload = JSON.parse(
|
|
114
|
+
Buffer.from(result.access_token.split(".")[1], "base64").toString()
|
|
115
|
+
);
|
|
116
|
+
email = payload.email || "unknown";
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + result.expires_in;
|
|
120
|
+
saveAuth({
|
|
121
|
+
access_token: result.access_token,
|
|
122
|
+
refresh_token: result.refresh_token,
|
|
123
|
+
expires_at: expiresAt
|
|
124
|
+
});
|
|
125
|
+
let orgId;
|
|
126
|
+
try {
|
|
127
|
+
const orgsRes = await api.get("/v1/orgs");
|
|
128
|
+
if (orgsRes.ok) {
|
|
129
|
+
const { orgs } = await orgsRes.json();
|
|
130
|
+
if (orgs.length === 1) {
|
|
131
|
+
orgId = orgs[0].id;
|
|
132
|
+
console.log(`Organization: ${orgs[0].name}`);
|
|
133
|
+
} else if (orgs.length > 1) {
|
|
134
|
+
console.log("\nSelect an organization:\n");
|
|
135
|
+
orgs.forEach((org, i) => {
|
|
136
|
+
console.log(` ${i + 1}) ${org.name} (${org.role})`);
|
|
137
|
+
});
|
|
138
|
+
console.log();
|
|
139
|
+
const rl = readline.createInterface({
|
|
140
|
+
input: process.stdin,
|
|
141
|
+
output: process.stdout
|
|
142
|
+
});
|
|
143
|
+
const choice = await new Promise((resolve) => {
|
|
144
|
+
rl.question("Enter number: ", (answer) => {
|
|
145
|
+
rl.close();
|
|
146
|
+
resolve(answer.trim());
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
const index = parseInt(choice, 10) - 1;
|
|
150
|
+
if (index >= 0 && index < orgs.length) {
|
|
151
|
+
orgId = orgs[index].id;
|
|
152
|
+
console.log(`Selected: ${orgs[index].name}`);
|
|
153
|
+
} else {
|
|
154
|
+
orgId = orgs[0].id;
|
|
155
|
+
console.log(`Defaulting to: ${orgs[0].name}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
saveAuth({
|
|
162
|
+
access_token: result.access_token,
|
|
163
|
+
refresh_token: result.refresh_token,
|
|
164
|
+
expires_at: expiresAt,
|
|
165
|
+
org_id: orgId
|
|
166
|
+
});
|
|
167
|
+
console.log(`Logged in as ${email}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/commands/deploy.ts
|
|
171
|
+
import path from "path";
|
|
172
|
+
async function deployCommand(dir, options) {
|
|
173
|
+
let appId;
|
|
174
|
+
let slug;
|
|
175
|
+
let appToken;
|
|
176
|
+
if (options?.from) {
|
|
177
|
+
const repo = options.from.replace(/^github:/, "");
|
|
178
|
+
console.log(`Deploying from ${repo}...`);
|
|
179
|
+
const res = await api.post("/v1/apps/deploy", { repo, branch: "main" });
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
const err = await res.json();
|
|
182
|
+
console.error(`Deploy failed: ${err.error || res.statusText}`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
const data = await res.json();
|
|
186
|
+
appId = data.app_id;
|
|
187
|
+
slug = data.slug;
|
|
188
|
+
appToken = data.app_token;
|
|
189
|
+
} else {
|
|
190
|
+
const resolvedDir = dir ? path.resolve(dir) : process.cwd();
|
|
191
|
+
const manifest = readManifest(resolvedDir);
|
|
192
|
+
const name = options?.name || manifest?.name || path.basename(resolvedDir);
|
|
193
|
+
const framework = manifest?.app?.framework || "vite-preact";
|
|
194
|
+
console.log(`Deploying ${name}...`);
|
|
195
|
+
const archive = createArchive(resolvedDir);
|
|
196
|
+
const formData = new FormData();
|
|
197
|
+
formData.append("source", new Blob([archive]), "source.tar.gz");
|
|
198
|
+
formData.append("name", name);
|
|
199
|
+
formData.append("framework", framework);
|
|
200
|
+
const res = await api.postForm("/v1/apps/deploy", formData);
|
|
201
|
+
if (!res.ok) {
|
|
202
|
+
const err = await res.json();
|
|
203
|
+
console.error(`Deploy failed: ${err.error || res.statusText}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const data = await res.json();
|
|
207
|
+
appId = data.app_id;
|
|
208
|
+
slug = data.slug;
|
|
209
|
+
appToken = data.app_token;
|
|
210
|
+
saveProject(resolvedDir, {
|
|
211
|
+
app_id: appId,
|
|
212
|
+
slug,
|
|
213
|
+
app_token: appToken
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const result = await pollUntilReady(appId);
|
|
217
|
+
if (result.status === "error") {
|
|
218
|
+
console.error(`
|
|
219
|
+
Deploy failed: ${result.error || "Unknown error"}
|
|
220
|
+
`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
const url = `https://${slug}.apps.clawnify.com`;
|
|
224
|
+
console.log(`
|
|
225
|
+
\u2713 Deployed! ${url}
|
|
226
|
+
`);
|
|
227
|
+
console.log(` Open: clawnify open ${slug}
|
|
228
|
+
`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/commands/ls.ts
|
|
232
|
+
async function lsCommand() {
|
|
233
|
+
const res = await api.get("/v1/apps");
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
console.error("Failed to list apps.");
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
const apps = await res.json();
|
|
239
|
+
if (apps.length === 0) {
|
|
240
|
+
console.log("No apps deployed yet.");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const nameWidth = Math.max(4, ...apps.map((a) => a.name.length));
|
|
244
|
+
const slugWidth = Math.max(4, ...apps.map((a) => a.slug.length));
|
|
245
|
+
console.log(
|
|
246
|
+
`${"Name".padEnd(nameWidth)} ${"Slug".padEnd(slugWidth)} ${"Status".padEnd(8)} URL`
|
|
247
|
+
);
|
|
248
|
+
console.log(
|
|
249
|
+
`${"\u2500".repeat(nameWidth)} ${"\u2500".repeat(slugWidth)} ${"\u2500".repeat(8)} ${"\u2500".repeat(30)}`
|
|
250
|
+
);
|
|
251
|
+
for (const app of apps) {
|
|
252
|
+
const url = `https://${app.slug}.apps.clawnify.com`;
|
|
253
|
+
console.log(
|
|
254
|
+
`${app.name.padEnd(nameWidth)} ${app.slug.padEnd(slugWidth)} ${app.status.padEnd(8)} ${url}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/commands/logs.ts
|
|
260
|
+
async function logsCommand(appId) {
|
|
261
|
+
const res = await api.get(`/v1/apps/${appId}/logs`);
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
console.error("Failed to fetch logs.");
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
const data = await res.json();
|
|
267
|
+
console.log(`Status: ${data.status}`);
|
|
268
|
+
if (data.status_message) {
|
|
269
|
+
console.log(`Message: ${data.status_message}`);
|
|
270
|
+
}
|
|
271
|
+
if (data.logs) {
|
|
272
|
+
console.log("\n--- Build Logs ---\n");
|
|
273
|
+
console.log(data.logs);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/commands/rm.ts
|
|
278
|
+
import readline2 from "readline";
|
|
279
|
+
async function rmCommand(appId) {
|
|
280
|
+
const res = await api.get(`/v1/apps/${appId}`);
|
|
281
|
+
if (!res.ok) {
|
|
282
|
+
console.error("App not found.");
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const app = await res.json();
|
|
286
|
+
const confirmed = await new Promise((resolve) => {
|
|
287
|
+
const rl = readline2.createInterface({
|
|
288
|
+
input: process.stdin,
|
|
289
|
+
output: process.stdout
|
|
290
|
+
});
|
|
291
|
+
rl.question(`Delete ${app.name}? (y/N) `, (answer) => {
|
|
292
|
+
rl.close();
|
|
293
|
+
resolve(answer.toLowerCase() === "y");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
if (!confirmed) {
|
|
297
|
+
console.log("Cancelled.");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const delRes = await api.del(`/v1/apps/${appId}`);
|
|
301
|
+
if (!delRes.ok) {
|
|
302
|
+
console.error("Failed to delete app.");
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
console.log(`Deleted ${app.name}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/commands/open.ts
|
|
309
|
+
import open2 from "open";
|
|
310
|
+
async function openCommand(slug) {
|
|
311
|
+
let appToken;
|
|
312
|
+
const project = readProject();
|
|
313
|
+
if (project?.slug === slug && project.app_token) {
|
|
314
|
+
appToken = project.app_token;
|
|
315
|
+
}
|
|
316
|
+
if (!appToken) {
|
|
317
|
+
const res = await api.get("/v1/apps");
|
|
318
|
+
if (res.ok) {
|
|
319
|
+
const apps = await res.json();
|
|
320
|
+
const app = apps.find((a) => a.slug === slug);
|
|
321
|
+
appToken = app?.app_token;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let url = `https://${slug}.apps.clawnify.com`;
|
|
325
|
+
if (appToken) {
|
|
326
|
+
url += `?token=${appToken}`;
|
|
327
|
+
}
|
|
328
|
+
await open2(url);
|
|
329
|
+
console.log("Opened in browser");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/commands/whoami.ts
|
|
333
|
+
async function whoamiCommand() {
|
|
334
|
+
const auth = loadAuth();
|
|
335
|
+
if (!auth) {
|
|
336
|
+
console.log("Not logged in");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const email = getEmailFromToken(auth.access_token) ?? "unknown";
|
|
340
|
+
console.log(`Email: ${email}`);
|
|
341
|
+
if (auth.org_id) {
|
|
342
|
+
console.log(`Org: ${auth.org_id}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/commands/init.ts
|
|
347
|
+
import fs from "fs";
|
|
348
|
+
import path2 from "path";
|
|
349
|
+
import readline3 from "readline";
|
|
350
|
+
function ask(question) {
|
|
351
|
+
const rl = readline3.createInterface({
|
|
352
|
+
input: process.stdin,
|
|
353
|
+
output: process.stdout
|
|
354
|
+
});
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
rl.question(question, (answer) => {
|
|
357
|
+
rl.close();
|
|
358
|
+
resolve(answer.trim());
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function slugify(name) {
|
|
363
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30);
|
|
364
|
+
}
|
|
365
|
+
async function initCommand(dir) {
|
|
366
|
+
const name = await ask("App name: ") || "my-app";
|
|
367
|
+
const slug = slugify(name);
|
|
368
|
+
const targetDir = dir ? path2.resolve(dir) : path2.resolve(slug);
|
|
369
|
+
console.log();
|
|
370
|
+
console.log(" Templates:");
|
|
371
|
+
console.log(" 1) blank \u2014 Empty app with a single API route and page");
|
|
372
|
+
console.log(" 2) crud \u2014 CRUD app with database, list, and form");
|
|
373
|
+
console.log();
|
|
374
|
+
const templateChoice = await ask("Template [1]: ") || "1";
|
|
375
|
+
const template = templateChoice === "2" ? "crud" : "blank";
|
|
376
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
377
|
+
console.error(`Directory ${targetDir} is not empty.`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
fs.mkdirSync(path2.join(targetDir, "src/server"), { recursive: true });
|
|
381
|
+
fs.mkdirSync(path2.join(targetDir, "src/client"), { recursive: true });
|
|
382
|
+
write(targetDir, "clawnify.json", JSON.stringify({
|
|
383
|
+
$schema: "https://app.clawnify.com/schema/v1/clawnify.json",
|
|
384
|
+
version: 1,
|
|
385
|
+
name,
|
|
386
|
+
description: "",
|
|
387
|
+
app: { framework: "preact+hono", database: true, storage: false }
|
|
388
|
+
}, null, 2));
|
|
389
|
+
write(targetDir, "package.json", JSON.stringify({
|
|
390
|
+
name: slug,
|
|
391
|
+
private: true,
|
|
392
|
+
type: "module",
|
|
393
|
+
scripts: {
|
|
394
|
+
dev: "wrangler dev",
|
|
395
|
+
build: "vite build"
|
|
396
|
+
},
|
|
397
|
+
dependencies: {
|
|
398
|
+
hono: "^4.0.0",
|
|
399
|
+
preact: "^10.0.0"
|
|
400
|
+
},
|
|
401
|
+
devDependencies: {
|
|
402
|
+
"@preact/preset-vite": "^2.0.0",
|
|
403
|
+
"vite": "^6.0.0",
|
|
404
|
+
"wrangler": "^4.0.0"
|
|
405
|
+
},
|
|
406
|
+
pnpm: { onlyBuiltDependencies: ["esbuild"] }
|
|
407
|
+
}, null, 2));
|
|
408
|
+
write(targetDir, "tsconfig.json", JSON.stringify({
|
|
409
|
+
compilerOptions: {
|
|
410
|
+
target: "ESNext",
|
|
411
|
+
module: "ESNext",
|
|
412
|
+
moduleResolution: "Bundler",
|
|
413
|
+
strict: true,
|
|
414
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
415
|
+
jsx: "react-jsx",
|
|
416
|
+
jsxImportSource: "preact",
|
|
417
|
+
noEmit: true,
|
|
418
|
+
skipLibCheck: true,
|
|
419
|
+
resolveJsonModule: true,
|
|
420
|
+
isolatedModules: true
|
|
421
|
+
},
|
|
422
|
+
include: ["src/**/*.ts", "src/**/*.tsx"],
|
|
423
|
+
exclude: ["node_modules", "dist"]
|
|
424
|
+
}, null, 2));
|
|
425
|
+
write(targetDir, "vite.config.ts", `import { defineConfig } from "vite";
|
|
426
|
+
import preact from "@preact/preset-vite";
|
|
427
|
+
|
|
428
|
+
export default defineConfig({
|
|
429
|
+
plugins: [preact()],
|
|
430
|
+
build: {
|
|
431
|
+
outDir: "dist",
|
|
432
|
+
emptyOutDir: true,
|
|
433
|
+
},
|
|
434
|
+
server: {
|
|
435
|
+
proxy: {
|
|
436
|
+
"/api": {
|
|
437
|
+
target: "http://localhost:8787",
|
|
438
|
+
changeOrigin: true,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
`);
|
|
444
|
+
write(targetDir, "wrangler.toml", `name = "${slug}"
|
|
445
|
+
main = "src/server/index.ts"
|
|
446
|
+
compatibility_date = "2025-04-01"
|
|
447
|
+
compatibility_flags = ["nodejs_compat"]
|
|
448
|
+
|
|
449
|
+
[[d1_databases]]
|
|
450
|
+
binding = "DB"
|
|
451
|
+
database_name = "${slug}-db"
|
|
452
|
+
database_id = "local"
|
|
453
|
+
|
|
454
|
+
[assets]
|
|
455
|
+
directory = "./dist"
|
|
456
|
+
binding = "ASSETS"
|
|
457
|
+
not_found_handling = "single-page-application"
|
|
458
|
+
`);
|
|
459
|
+
write(targetDir, "index.html", `<!DOCTYPE html>
|
|
460
|
+
<html lang="en">
|
|
461
|
+
<head>
|
|
462
|
+
<meta charset="UTF-8" />
|
|
463
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
464
|
+
<title>${name}</title>
|
|
465
|
+
</head>
|
|
466
|
+
<body>
|
|
467
|
+
<div id="app"></div>
|
|
468
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
469
|
+
</body>
|
|
470
|
+
</html>
|
|
471
|
+
`);
|
|
472
|
+
write(targetDir, ".gitignore", `node_modules
|
|
473
|
+
dist
|
|
474
|
+
.wrangler
|
|
475
|
+
.clawnify
|
|
476
|
+
`);
|
|
477
|
+
write(targetDir, "src/server/db.ts", `let _db: D1Database;
|
|
478
|
+
|
|
479
|
+
export function initDB(db: D1Database) {
|
|
480
|
+
_db = db;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function query<T = Record<string, unknown>>(
|
|
484
|
+
sql: string,
|
|
485
|
+
params: unknown[] = [],
|
|
486
|
+
): Promise<T[]> {
|
|
487
|
+
const stmt = _db.prepare(sql).bind(...params);
|
|
488
|
+
const { results } = await stmt.all<T>();
|
|
489
|
+
return results ?? [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function get<T = Record<string, unknown>>(
|
|
493
|
+
sql: string,
|
|
494
|
+
params: unknown[] = [],
|
|
495
|
+
): Promise<T | undefined> {
|
|
496
|
+
const stmt = _db.prepare(sql).bind(...params);
|
|
497
|
+
const row = await stmt.first<T>();
|
|
498
|
+
return row ?? undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function run(
|
|
502
|
+
sql: string,
|
|
503
|
+
params: unknown[] = [],
|
|
504
|
+
): Promise<{ changes: number; lastInsertRowid: number }> {
|
|
505
|
+
const stmt = _db.prepare(sql).bind(...params);
|
|
506
|
+
const result = await stmt.run();
|
|
507
|
+
return {
|
|
508
|
+
changes: result.meta?.changes ?? 0,
|
|
509
|
+
lastInsertRowid: Number(result.meta?.last_row_id ?? 0),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
`);
|
|
513
|
+
write(targetDir, "src/server/index.ts", `import { Hono } from "hono";
|
|
514
|
+
import { initDB } from "./db";
|
|
515
|
+
import api from "./routes";
|
|
516
|
+
|
|
517
|
+
type Env = { Bindings: { DB: D1Database } };
|
|
518
|
+
|
|
519
|
+
const app = new Hono<Env>();
|
|
520
|
+
|
|
521
|
+
app.use("*", async (c, next) => {
|
|
522
|
+
initDB(c.env.DB);
|
|
523
|
+
await next();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
app.route("/", api);
|
|
527
|
+
|
|
528
|
+
export default app;
|
|
529
|
+
`);
|
|
530
|
+
write(targetDir, "src/client/main.tsx", `import { render } from "preact";
|
|
531
|
+
import { App } from "./app";
|
|
532
|
+
|
|
533
|
+
render(<App />, document.getElementById("app")!);
|
|
534
|
+
`);
|
|
535
|
+
if (template === "crud") {
|
|
536
|
+
writeCrudTemplate(targetDir, name);
|
|
537
|
+
} else {
|
|
538
|
+
writeBlankTemplate(targetDir, name);
|
|
539
|
+
}
|
|
540
|
+
console.log();
|
|
541
|
+
console.log(` Created ${name} in ${path2.relative(process.cwd(), targetDir)}/`);
|
|
542
|
+
console.log();
|
|
543
|
+
console.log(" Get started:");
|
|
544
|
+
console.log(` cd ${path2.relative(process.cwd(), targetDir)}`);
|
|
545
|
+
console.log(" pnpm install");
|
|
546
|
+
console.log(" pnpm dev # local dev at http://localhost:8787");
|
|
547
|
+
console.log(" clawnify deploy # deploy to production");
|
|
548
|
+
console.log();
|
|
549
|
+
}
|
|
550
|
+
function writeBlankTemplate(dir, name) {
|
|
551
|
+
write(dir, "src/server/schema.sql", `CREATE TABLE IF NOT EXISTS items (
|
|
552
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
553
|
+
name TEXT NOT NULL,
|
|
554
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
555
|
+
);
|
|
556
|
+
`);
|
|
557
|
+
write(dir, "src/server/routes.ts", `import { Hono } from "hono";
|
|
558
|
+
import { query } from "./db";
|
|
559
|
+
|
|
560
|
+
const api = new Hono();
|
|
561
|
+
|
|
562
|
+
api.get("/api/items", async (c) => {
|
|
563
|
+
const items = await query("SELECT * FROM items ORDER BY created_at DESC");
|
|
564
|
+
return c.json(items);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
export default api;
|
|
568
|
+
`);
|
|
569
|
+
write(dir, "src/client/app.tsx", `import { useState, useEffect } from "preact/hooks";
|
|
570
|
+
|
|
571
|
+
export function App() {
|
|
572
|
+
const [items, setItems] = useState<any[]>([]);
|
|
573
|
+
|
|
574
|
+
useEffect(() => {
|
|
575
|
+
fetch("/api/items").then((r) => r.json()).then(setItems);
|
|
576
|
+
}, []);
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
<div style={{ maxWidth: 480, margin: "40px auto", fontFamily: "system-ui" }}>
|
|
580
|
+
<h1>${name}</h1>
|
|
581
|
+
<ul>
|
|
582
|
+
{items.map((item: any) => (
|
|
583
|
+
<li key={item.id}>{item.name}</li>
|
|
584
|
+
))}
|
|
585
|
+
</ul>
|
|
586
|
+
{items.length === 0 && <p style={{ color: "#999" }}>No items yet.</p>}
|
|
587
|
+
</div>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
`);
|
|
591
|
+
}
|
|
592
|
+
function writeCrudTemplate(dir, name) {
|
|
593
|
+
write(dir, "src/server/schema.sql", `CREATE TABLE IF NOT EXISTS items (
|
|
594
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
595
|
+
title TEXT NOT NULL,
|
|
596
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
597
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
598
|
+
);
|
|
599
|
+
`);
|
|
600
|
+
write(dir, "src/server/routes.ts", `import { Hono } from "hono";
|
|
601
|
+
import { query, run } from "./db";
|
|
602
|
+
|
|
603
|
+
const api = new Hono();
|
|
604
|
+
|
|
605
|
+
api.get("/api/items", async (c) => {
|
|
606
|
+
const items = await query("SELECT * FROM items ORDER BY created_at DESC");
|
|
607
|
+
return c.json(items);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
api.post("/api/items", async (c) => {
|
|
611
|
+
const { title } = await c.req.json<{ title: string }>();
|
|
612
|
+
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
|
|
613
|
+
const result = await run("INSERT INTO items (title) VALUES (?)", [title.trim()]);
|
|
614
|
+
return c.json({ id: result.lastInsertRowid, title: title.trim(), status: "active" }, 201);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
api.patch("/api/items/:id", async (c) => {
|
|
618
|
+
const id = c.req.param("id");
|
|
619
|
+
const body = await c.req.json<{ title?: string; status?: string }>();
|
|
620
|
+
const sets: string[] = [];
|
|
621
|
+
const params: unknown[] = [];
|
|
622
|
+
if (body.title !== undefined) { sets.push("title = ?"); params.push(body.title); }
|
|
623
|
+
if (body.status !== undefined) { sets.push("status = ?"); params.push(body.status); }
|
|
624
|
+
if (sets.length === 0) return c.json({ error: "Nothing to update" }, 400);
|
|
625
|
+
params.push(id);
|
|
626
|
+
await run(\`UPDATE items SET \${sets.join(", ")} WHERE id = ?\`, params);
|
|
627
|
+
return c.json({ ok: true });
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
api.delete("/api/items/:id", async (c) => {
|
|
631
|
+
const id = c.req.param("id");
|
|
632
|
+
await run("DELETE FROM items WHERE id = ?", [id]);
|
|
633
|
+
return c.json({ ok: true });
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
export default api;
|
|
637
|
+
`);
|
|
638
|
+
write(dir, "src/client/app.tsx", `import { useState, useEffect } from "preact/hooks";
|
|
639
|
+
|
|
640
|
+
interface Item {
|
|
641
|
+
id: number;
|
|
642
|
+
title: string;
|
|
643
|
+
status: string;
|
|
644
|
+
created_at: string;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function App() {
|
|
648
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
649
|
+
const [title, setTitle] = useState("");
|
|
650
|
+
|
|
651
|
+
async function load() {
|
|
652
|
+
const res = await fetch("/api/items");
|
|
653
|
+
setItems(await res.json());
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
useEffect(() => { load(); }, []);
|
|
657
|
+
|
|
658
|
+
async function addItem(e: Event) {
|
|
659
|
+
e.preventDefault();
|
|
660
|
+
if (!title.trim()) return;
|
|
661
|
+
await fetch("/api/items", {
|
|
662
|
+
method: "POST",
|
|
663
|
+
headers: { "Content-Type": "application/json" },
|
|
664
|
+
body: JSON.stringify({ title }),
|
|
665
|
+
});
|
|
666
|
+
setTitle("");
|
|
667
|
+
load();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function toggleStatus(item: Item) {
|
|
671
|
+
const next = item.status === "active" ? "done" : "active";
|
|
672
|
+
await fetch(\`/api/items/\${item.id}\`, {
|
|
673
|
+
method: "PATCH",
|
|
674
|
+
headers: { "Content-Type": "application/json" },
|
|
675
|
+
body: JSON.stringify({ status: next }),
|
|
676
|
+
});
|
|
677
|
+
load();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function deleteItem(id: number) {
|
|
681
|
+
await fetch(\`/api/items/\${id}\`, { method: "DELETE" });
|
|
682
|
+
load();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<div style={{ maxWidth: 480, margin: "40px auto", fontFamily: "system-ui" }}>
|
|
687
|
+
<h1>${name}</h1>
|
|
688
|
+
<form onSubmit={addItem} style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
|
689
|
+
<input
|
|
690
|
+
value={title}
|
|
691
|
+
onInput={(e) => setTitle((e.target as HTMLInputElement).value)}
|
|
692
|
+
placeholder="Add an item..."
|
|
693
|
+
style={{ flex: 1, padding: "8px 12px", borderRadius: 6, border: "1px solid #ddd" }}
|
|
694
|
+
/>
|
|
695
|
+
<button type="submit" style={{ padding: "8px 16px", borderRadius: 6, background: "#111", color: "#fff", border: "none", cursor: "pointer" }}>
|
|
696
|
+
Add
|
|
697
|
+
</button>
|
|
698
|
+
</form>
|
|
699
|
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
700
|
+
{items.map((item) => (
|
|
701
|
+
<li key={item.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 0", borderBottom: "1px solid #eee" }}>
|
|
702
|
+
<input type="checkbox" checked={item.status === "done"} onChange={() => toggleStatus(item)} />
|
|
703
|
+
<span style={{ flex: 1, textDecoration: item.status === "done" ? "line-through" : "none", color: item.status === "done" ? "#999" : "#111" }}>
|
|
704
|
+
{item.title}
|
|
705
|
+
</span>
|
|
706
|
+
<button onClick={() => deleteItem(item.id)} style={{ background: "none", border: "none", color: "#999", cursor: "pointer" }}>
|
|
707
|
+
\xD7
|
|
708
|
+
</button>
|
|
709
|
+
</li>
|
|
710
|
+
))}
|
|
711
|
+
</ul>
|
|
712
|
+
{items.length === 0 && <p style={{ color: "#999" }}>No items yet. Add one above.</p>}
|
|
713
|
+
</div>
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
`);
|
|
717
|
+
}
|
|
718
|
+
function write(dir, file, content) {
|
|
719
|
+
const fullPath = path2.join(dir, file);
|
|
720
|
+
fs.mkdirSync(path2.dirname(fullPath), { recursive: true });
|
|
721
|
+
fs.writeFileSync(fullPath, content);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/cli.ts
|
|
725
|
+
function runCli() {
|
|
726
|
+
const program = new Command();
|
|
727
|
+
program.name("clawnify").description("Deploy apps to Clawnify").version("0.1.0");
|
|
728
|
+
program.command("init [dir]").description("Scaffold a new Clawnify app").action(initCommand);
|
|
729
|
+
program.command("login").description("Authenticate with Clawnify").action(loginCommand);
|
|
730
|
+
program.command("deploy [dir]").description("Deploy an app to Clawnify").option("--from <repo>", "Deploy from a GitHub repo (owner/repo)").option("--name <name>", "Override app name").action(deployCommand);
|
|
731
|
+
program.command("ls").description("List deployed apps").action(lsCommand);
|
|
732
|
+
program.command("logs <app-id>").description("Show build logs for an app").action(logsCommand);
|
|
733
|
+
program.command("rm <app-id>").description("Delete an app").action(rmCommand);
|
|
734
|
+
program.command("open <slug>").description("Open an app in the browser").action(openCommand);
|
|
735
|
+
program.command("whoami").description("Show current user info").action(whoamiCommand);
|
|
736
|
+
program.parse();
|
|
737
|
+
}
|
|
738
|
+
export {
|
|
739
|
+
runCli
|
|
740
|
+
};
|
|
741
|
+
//# sourceMappingURL=cli-MDRNA6W2.js.map
|