@tryclean/create 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/dist/index.js ADDED
@@ -0,0 +1,1835 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ banner,
4
+ cancelled,
5
+ isCancel
6
+ } from "./chunk-GALY4XWB.js";
7
+ import {
8
+ exec,
9
+ execLive
10
+ } from "./chunk-WHE6TEJG.js";
11
+
12
+ // src/index.ts
13
+ import { existsSync as existsSync3 } from "fs";
14
+ import { join as join4 } from "path";
15
+
16
+ // src/commands/setup.ts
17
+ import * as p11 from "@clack/prompts";
18
+ import pc10 from "picocolors";
19
+
20
+ // src/steps/preflight.ts
21
+ import * as p2 from "@clack/prompts";
22
+ import pc from "picocolors";
23
+
24
+ // src/lib/docker.ts
25
+ import * as p from "@clack/prompts";
26
+ async function isDockerInstalled() {
27
+ try {
28
+ await exec("docker --version");
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function isDockerRunning() {
35
+ try {
36
+ await exec("docker info", { timeout: 5e3 });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+ async function startDocker() {
43
+ if (process.platform === "darwin") {
44
+ await exec("open -a Docker");
45
+ } else if (process.platform === "linux") {
46
+ await exec("systemctl start docker");
47
+ } else {
48
+ throw new Error("Unsupported platform for auto-start");
49
+ }
50
+ }
51
+ async function ensureDocker() {
52
+ if (await isDockerRunning()) return;
53
+ const s = p.spinner();
54
+ s.start("Docker is not running \u2014 starting Docker Desktop");
55
+ try {
56
+ await startDocker();
57
+ } catch {
58
+ }
59
+ for (let i = 0; i < 30; i++) {
60
+ await new Promise((r) => setTimeout(r, 2e3));
61
+ if (await isDockerRunning()) {
62
+ s.stop("Docker started");
63
+ return;
64
+ }
65
+ }
66
+ s.stop("Docker not responding yet", 2);
67
+ p.log.info("Please start Docker Desktop manually.");
68
+ const s2 = p.spinner();
69
+ s2.start("Waiting for Docker (60s)");
70
+ for (let i = 0; i < 30; i++) {
71
+ await new Promise((r) => setTimeout(r, 2e3));
72
+ if (await isDockerRunning()) {
73
+ s2.stop("Docker is running");
74
+ return;
75
+ }
76
+ }
77
+ s2.stop("Docker still not available", 1);
78
+ p.log.error("Cannot proceed without Docker. Please start Docker and try again.");
79
+ process.exit(1);
80
+ }
81
+ async function hasComposePlugin() {
82
+ try {
83
+ await exec("docker compose version");
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+ async function composePullLive(cwd) {
90
+ await ensureDocker();
91
+ execLive("docker compose pull", { cwd });
92
+ }
93
+ async function composeUp(cwd) {
94
+ await ensureDocker();
95
+ await exec("docker compose up -d", { cwd });
96
+ }
97
+ async function composePs(cwd) {
98
+ await ensureDocker();
99
+ const { stdout } = await exec("docker compose ps", { cwd });
100
+ return stdout;
101
+ }
102
+ async function composeExec(cwd, service, command2) {
103
+ await ensureDocker();
104
+ const { stdout } = await exec(`docker compose exec -T ${service} ${command2}`, { cwd });
105
+ return stdout;
106
+ }
107
+ async function composeCp(cwd, src, dest) {
108
+ await ensureDocker();
109
+ await exec(`docker compose cp ${src} ${dest}`, { cwd });
110
+ }
111
+
112
+ // src/lib/network.ts
113
+ import { createServer } from "net";
114
+ import { hostname } from "os";
115
+ function checkPort(port) {
116
+ return new Promise((resolve) => {
117
+ const server = createServer();
118
+ server.once("error", () => resolve(false));
119
+ server.once("listening", () => {
120
+ server.close(() => resolve(true));
121
+ });
122
+ server.listen(port, "0.0.0.0");
123
+ });
124
+ }
125
+ async function getProcessOnPort(port) {
126
+ try {
127
+ const { stdout } = await exec(`lsof -ti :${port}`);
128
+ const pid = stdout.trim().split("\n")[0];
129
+ if (!pid) return null;
130
+ const { stdout: psOut } = await exec(`ps -p ${pid} -o comm=`);
131
+ return { pid, command: psOut.trim() };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+ async function getDockerContainerOnPort(port) {
137
+ try {
138
+ const { stdout } = await exec(`docker ps --filter "publish=${port}" --format "{{.Names}}"`);
139
+ return stdout.trim().split("\n").filter(Boolean);
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+ function isDockerProcess(command2) {
145
+ return command2.includes("docker") || command2.includes("com.docker");
146
+ }
147
+ async function killProcessOnPort(port) {
148
+ try {
149
+ const proc = await getProcessOnPort(port);
150
+ if (proc && isDockerProcess(proc.command)) {
151
+ const containers = await getDockerContainerOnPort(port);
152
+ if (containers.length > 0) {
153
+ for (const name of containers) {
154
+ await exec(`docker stop ${name}`);
155
+ }
156
+ await new Promise((r) => setTimeout(r, 1e3));
157
+ return true;
158
+ }
159
+ }
160
+ const { stdout } = await exec(`lsof -ti :${port}`);
161
+ const pids = stdout.trim().split("\n").filter(Boolean);
162
+ if (pids.length === 0) return false;
163
+ for (const pid of pids) {
164
+ await exec(`kill -9 ${pid}`);
165
+ }
166
+ await new Promise((r) => setTimeout(r, 500));
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+ function getHostname() {
173
+ return hostname();
174
+ }
175
+
176
+ // src/steps/preflight.ts
177
+ async function preflight() {
178
+ p2.log.step(pc.bold("Step 1: Preflight checks"));
179
+ const arch = process.arch;
180
+ const platform = process.platform;
181
+ const needsEmulation = arch === "arm64";
182
+ p2.log.info(
183
+ `${pc.dim("System:")} ${platform}/${arch}` + (needsEmulation ? ` ${pc.yellow("(ARM \u2014 will use amd64 emulation for images)")}` : "")
184
+ );
185
+ const s = p2.spinner();
186
+ s.start("Checking Docker installation");
187
+ const dockerInstalled = await isDockerInstalled();
188
+ if (!dockerInstalled) {
189
+ s.stop("Docker not found", 2);
190
+ const installCmd = process.platform === "darwin" ? "brew install --cask docker" : process.platform === "linux" ? "curl -fsSL https://get.docker.com | sh" : null;
191
+ if (installCmd) {
192
+ const install = await p2.confirm({
193
+ message: `Docker is required. Install it now? (${pc.cyan(installCmd)})`,
194
+ initialValue: true
195
+ });
196
+ if (isCancel(install)) cancelled();
197
+ if (install) {
198
+ const { exec: exec2 } = await import("./exec-OIKPD6DO.js");
199
+ s.start("Installing Docker");
200
+ try {
201
+ await exec2(installCmd);
202
+ s.stop("Docker installed");
203
+ } catch (err) {
204
+ s.stop("Installation failed", 1);
205
+ p2.log.error(
206
+ `Auto-install failed: ${err.message}
207
+ Install manually from ${pc.underline("https://docs.docker.com/get-docker/")}`
208
+ );
209
+ process.exit(1);
210
+ }
211
+ } else {
212
+ p2.log.error(`Install Docker from ${pc.underline("https://docs.docker.com/get-docker/")} and try again.`);
213
+ process.exit(1);
214
+ }
215
+ } else {
216
+ p2.log.error(`Install Docker from ${pc.underline("https://docs.docker.com/get-docker/")} and try again.`);
217
+ process.exit(1);
218
+ }
219
+ } else {
220
+ s.stop("Docker installed");
221
+ }
222
+ s.start("Checking Docker daemon");
223
+ let dockerRunning = await isDockerRunning();
224
+ if (!dockerRunning) {
225
+ s.stop("Docker not running", 2);
226
+ s.start("Starting Docker Desktop");
227
+ try {
228
+ await startDocker();
229
+ for (let i = 0; i < 30; i++) {
230
+ await new Promise((r) => setTimeout(r, 2e3));
231
+ dockerRunning = await isDockerRunning();
232
+ if (dockerRunning) break;
233
+ }
234
+ } catch {
235
+ }
236
+ if (!dockerRunning) {
237
+ s.stop("Could not start Docker", 1);
238
+ p2.log.error("Docker daemon could not be started. Please start Docker Desktop manually and try again.");
239
+ process.exit(1);
240
+ }
241
+ s.stop("Docker started");
242
+ } else {
243
+ s.stop("Docker daemon running");
244
+ }
245
+ s.start("Checking docker compose");
246
+ const compose = await hasComposePlugin();
247
+ if (!compose) {
248
+ s.stop("docker compose not found", 1);
249
+ p2.log.error(
250
+ `docker compose plugin is required.
251
+ It's included with Docker Desktop, or install it:
252
+ ${pc.cyan("https://docs.docker.com/compose/install/")}`
253
+ );
254
+ process.exit(1);
255
+ }
256
+ s.stop("docker compose available");
257
+ await resolvePort(8e3, "engine");
258
+ await resolvePort(3e3, "dashboard");
259
+ p2.log.success("Preflight checks passed");
260
+ return { arch, platform, needsEmulation };
261
+ }
262
+ async function resolvePort(port, label) {
263
+ const free = await checkPort(port);
264
+ if (free) return;
265
+ const containers = await getDockerContainerOnPort(port);
266
+ if (containers.length > 0) {
267
+ p2.log.info(`Stopping container ${pc.bold(containers.join(", "))} on port ${port}`);
268
+ const { exec: exec2 } = await import("./exec-OIKPD6DO.js");
269
+ for (const name of containers) {
270
+ try {
271
+ await exec2(`docker stop ${name}`);
272
+ } catch {
273
+ }
274
+ }
275
+ await new Promise((r) => setTimeout(r, 1e3));
276
+ const nowFree = await checkPort(port);
277
+ if (nowFree) {
278
+ p2.log.success(`Port ${port} freed`);
279
+ } else {
280
+ p2.log.warn(`Port ${port} still in use \u2014 will retry at launch`);
281
+ }
282
+ return;
283
+ }
284
+ const proc = await getProcessOnPort(port);
285
+ const procInfo = proc ? `${pc.bold(proc.command)} (PID ${proc.pid})` : "unknown process";
286
+ p2.log.warn(`Port ${pc.bold(String(port))} (${label}) is in use by ${procInfo}`);
287
+ const action = await p2.select({
288
+ message: `How to resolve port ${port}?`,
289
+ options: [
290
+ { value: "kill", label: `Kill ${proc?.command ?? "process"} on port ${port}`, hint: "recommended" },
291
+ { value: "continue", label: "Continue anyway", hint: "may cause errors later" },
292
+ { value: "cancel", label: "Cancel setup" }
293
+ ]
294
+ });
295
+ if (isCancel(action)) cancelled();
296
+ if (action === "kill") {
297
+ const killed = await killProcessOnPort(port);
298
+ if (killed) {
299
+ const nowFree = await checkPort(port);
300
+ if (nowFree) {
301
+ p2.log.success(`Port ${port} freed`);
302
+ return;
303
+ }
304
+ }
305
+ p2.log.warn(`Could not free port ${port}. Continuing anyway.`);
306
+ } else if (action === "cancel") {
307
+ cancelled();
308
+ }
309
+ }
310
+
311
+ // src/steps/license.ts
312
+ import * as p3 from "@clack/prompts";
313
+ import pc2 from "picocolors";
314
+
315
+ // src/lib/license.ts
316
+ import { createVerify } from "crypto";
317
+ import { Buffer } from "buffer";
318
+ var PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
319
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7wpHt0MYEbK24cyybwnfhsEapBcz
320
+ Kyl7L9AauL4spTgX2a6x213pSI0N88VQm2i4gUeCAWhssjiYH370g+sYAQ==
321
+ -----END PUBLIC KEY-----`;
322
+ function base64UrlDecode(str) {
323
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
324
+ return Buffer.from(padded, "base64");
325
+ }
326
+ function decodeLicensePayload(token) {
327
+ const parts = token.split(".");
328
+ if (parts.length !== 3) {
329
+ throw new Error("Invalid JWT format \u2014 expected 3 dot-separated parts");
330
+ }
331
+ try {
332
+ const payload = JSON.parse(base64UrlDecode(parts[1]).toString("utf-8"));
333
+ return payload;
334
+ } catch {
335
+ throw new Error("Failed to decode JWT payload");
336
+ }
337
+ }
338
+ function rawSigToDer(raw) {
339
+ if (raw.length !== 64) throw new Error("Expected 64-byte raw signature");
340
+ const r = raw.subarray(0, 32);
341
+ const s = raw.subarray(32, 64);
342
+ function intBytes(buf) {
343
+ let i = 0;
344
+ while (i < buf.length - 1 && buf[i] === 0) i++;
345
+ const trimmed = buf.subarray(i);
346
+ if (trimmed[0] & 128) {
347
+ return Buffer.concat([Buffer.from([0]), trimmed]);
348
+ }
349
+ return trimmed;
350
+ }
351
+ const rDer = intBytes(r);
352
+ const sDer = intBytes(s);
353
+ const rTlv = Buffer.concat([Buffer.from([2, rDer.length]), rDer]);
354
+ const sTlv = Buffer.concat([Buffer.from([2, sDer.length]), sDer]);
355
+ const body = Buffer.concat([rTlv, sTlv]);
356
+ return Buffer.concat([Buffer.from([48, body.length]), body]);
357
+ }
358
+ function verifyLicenseSignature(token) {
359
+ const parts = token.split(".");
360
+ if (parts.length !== 3) return false;
361
+ const signedContent = `${parts[0]}.${parts[1]}`;
362
+ const rawSig = base64UrlDecode(parts[2]);
363
+ const derSig = rawSigToDer(rawSig);
364
+ const verify = createVerify("SHA256");
365
+ verify.update(signedContent);
366
+ verify.end();
367
+ return verify.verify(PUBLIC_KEY, derSig);
368
+ }
369
+ function validateLicense(token) {
370
+ if (!verifyLicenseSignature(token)) {
371
+ throw new Error("Invalid license signature \u2014 key may be tampered or corrupted");
372
+ }
373
+ const claims = decodeLicensePayload(token);
374
+ const required = ["sub", "tier", "max_repos", "max_users", "exp"];
375
+ const missing = required.filter((c) => !(c in claims));
376
+ if (missing.length > 0) {
377
+ throw new Error(`Malformed license: missing ${missing.join(", ")}`);
378
+ }
379
+ const now = Math.floor(Date.now() / 1e3);
380
+ if (claims.exp < now) {
381
+ const expDate = new Date(claims.exp * 1e3).toISOString().split("T")[0];
382
+ throw new Error(`License expired on ${expDate}`);
383
+ }
384
+ return claims;
385
+ }
386
+ function formatExpiry(exp) {
387
+ const date = new Date(exp * 1e3);
388
+ const now = Date.now();
389
+ const daysLeft = Math.ceil((date.getTime() - now) / (1e3 * 60 * 60 * 24));
390
+ const dateStr = date.toISOString().split("T")[0];
391
+ return `${dateStr} (${daysLeft} days remaining)`;
392
+ }
393
+ async function activateLicense(code) {
394
+ const resp = await fetch("https://api.tryclean.com/activate", {
395
+ method: "POST",
396
+ headers: { "Content-Type": "application/json" },
397
+ body: JSON.stringify({ code })
398
+ });
399
+ if (!resp.ok) {
400
+ const text7 = await resp.text().catch(() => "");
401
+ throw new Error(`Activation failed (${resp.status}): ${text7 || resp.statusText}`);
402
+ }
403
+ const data = await resp.json();
404
+ return data.license_key;
405
+ }
406
+
407
+ // src/steps/license.ts
408
+ async function licenseStep() {
409
+ p3.log.step(pc2.bold("Step 2: License activation"));
410
+ const method = await p3.select({
411
+ message: "How would you like to activate your license?",
412
+ options: [
413
+ {
414
+ value: "code",
415
+ label: "Activation code",
416
+ hint: "from your license email"
417
+ },
418
+ {
419
+ value: "jwt",
420
+ label: "Paste license key (JWT)",
421
+ hint: "for airgapped / offline setups"
422
+ }
423
+ ]
424
+ });
425
+ if (isCancel(method)) cancelled();
426
+ let licenseKey;
427
+ if (method === "code") {
428
+ const code = await p3.text({
429
+ message: "Enter your activation code:",
430
+ placeholder: "CLEAN-XXXX-XXXX-XXXX",
431
+ validate(value) {
432
+ if (!value.trim()) return "Activation code is required";
433
+ }
434
+ });
435
+ if (isCancel(code)) cancelled();
436
+ const s2 = p3.spinner();
437
+ s2.start("Activating license");
438
+ try {
439
+ licenseKey = await activateLicense(code);
440
+ s2.stop("License activated");
441
+ } catch (err) {
442
+ s2.stop("Activation failed", 1);
443
+ p3.log.warn(
444
+ `Could not activate online: ${err.message}
445
+ You can paste your license key (JWT) directly instead.`
446
+ );
447
+ const jwt = await p3.text({
448
+ message: "Paste your license key (JWT):",
449
+ validate(value) {
450
+ if (!value.trim()) return "License key is required";
451
+ if (value.split(".").length !== 3) return "Invalid JWT format";
452
+ }
453
+ });
454
+ if (isCancel(jwt)) cancelled();
455
+ licenseKey = jwt.trim();
456
+ }
457
+ } else {
458
+ const jwt = await p3.text({
459
+ message: "Paste your license key (JWT):",
460
+ validate(value) {
461
+ if (!value.trim()) return "License key is required";
462
+ if (value.split(".").length !== 3) return "Invalid JWT format";
463
+ }
464
+ });
465
+ if (isCancel(jwt)) cancelled();
466
+ licenseKey = jwt.trim();
467
+ }
468
+ const s = p3.spinner();
469
+ s.start("Validating license");
470
+ let claims;
471
+ try {
472
+ claims = validateLicense(licenseKey);
473
+ } catch (err) {
474
+ s.stop("Validation failed", 1);
475
+ p3.log.error(err.message);
476
+ try {
477
+ const decoded = decodeLicensePayload(licenseKey);
478
+ p3.log.info(
479
+ ` Decoded claims: customer=${decoded.sub}, tier=${decoded.tier}, exp=${decoded.exp}`
480
+ );
481
+ } catch {
482
+ }
483
+ return process.exit(1);
484
+ }
485
+ s.stop("License valid");
486
+ p3.log.info(
487
+ `${pc2.dim("Customer")} ${claims.sub}
488
+ ${pc2.dim("Tier")} ${claims.tier}
489
+ ${pc2.dim("Max repos")} ${claims.max_repos}
490
+ ${pc2.dim("Max users")} ${claims.max_users}
491
+ ${pc2.dim("Expires")} ${formatExpiry(claims.exp)}`
492
+ );
493
+ p3.log.success("License validated");
494
+ return { licenseKey, claims };
495
+ }
496
+
497
+ // src/steps/dashboard-url.ts
498
+ import * as p4 from "@clack/prompts";
499
+ import pc3 from "picocolors";
500
+ async function dashboardUrlStep() {
501
+ p4.log.step(pc3.bold("Step 3: Dashboard URL"));
502
+ const host = getHostname();
503
+ const defaultUrl = "http://localhost:3000";
504
+ const url = await p4.text({
505
+ message: "Dashboard URL (used for OAuth callback):",
506
+ placeholder: defaultUrl,
507
+ initialValue: defaultUrl,
508
+ validate(value) {
509
+ if (!value.trim()) return "URL is required";
510
+ try {
511
+ new URL(value);
512
+ } catch {
513
+ return "Invalid URL format (e.g., http://localhost:3000 or https://clean.example.com)";
514
+ }
515
+ }
516
+ });
517
+ if (isCancel(url)) cancelled();
518
+ p4.log.info(
519
+ `${pc3.dim("GitHub OAuth callback will be:")}
520
+ ${url.replace(/\/$/, "")}/api/auth/callback/github`
521
+ );
522
+ return url;
523
+ }
524
+
525
+ // src/steps/github-oauth.ts
526
+ import * as p5 from "@clack/prompts";
527
+ import pc4 from "picocolors";
528
+ async function githubOauthStep(authUrl) {
529
+ p5.log.step(pc4.bold("Step 4: GitHub OAuth App"));
530
+ const callbackUrl = `${authUrl.replace(/\/$/, "")}/api/auth/callback/github`;
531
+ p5.log.message(
532
+ `Create a GitHub OAuth App:
533
+
534
+ 1. Go to ${pc4.underline("https://github.com/settings/developers")}
535
+ 2. Click ${pc4.bold("New OAuth App")}
536
+ 3. Fill in:
537
+ ${pc4.dim("Application name:")} Clean Dashboard
538
+ ${pc4.dim("Homepage URL:")} ${authUrl}
539
+ ${pc4.dim("Callback URL:")} ${pc4.cyan(callbackUrl)}
540
+ 4. Click ${pc4.bold("Register application")}
541
+ 5. Copy the ${pc4.bold("Client ID")} and generate a ${pc4.bold("Client Secret")}`
542
+ );
543
+ const openBrowser = await p5.confirm({
544
+ message: "Open GitHub developer settings in your browser?",
545
+ initialValue: true
546
+ });
547
+ if (isCancel(openBrowser)) cancelled();
548
+ if (openBrowser) {
549
+ const { exec: exec2 } = await import("./exec-OIKPD6DO.js");
550
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
551
+ try {
552
+ await exec2(`${openCmd} "https://github.com/settings/developers"`);
553
+ } catch {
554
+ p5.log.warn("Could not open browser. Please navigate to the URL manually.");
555
+ }
556
+ }
557
+ const githubId = await p5.text({
558
+ message: "GitHub OAuth Client ID:",
559
+ placeholder: "Ov23li...",
560
+ validate(value) {
561
+ if (!value.trim()) return "Client ID is required";
562
+ if (value.trim().length < 10) return "Client ID looks too short";
563
+ }
564
+ });
565
+ if (isCancel(githubId)) cancelled();
566
+ const githubSecret = await p5.text({
567
+ message: "GitHub OAuth Client Secret:",
568
+ placeholder: "paste secret here",
569
+ validate(value) {
570
+ if (!value.trim()) return "Client Secret is required";
571
+ if (value.trim().length < 20) return "Client Secret looks too short";
572
+ }
573
+ });
574
+ if (isCancel(githubSecret)) cancelled();
575
+ p5.log.success("GitHub OAuth configured");
576
+ return {
577
+ githubId: githubId.trim(),
578
+ githubSecret: githubSecret.trim()
579
+ };
580
+ }
581
+
582
+ // src/steps/access-control.ts
583
+ import * as p6 from "@clack/prompts";
584
+ import pc5 from "picocolors";
585
+ async function accessControlStep() {
586
+ p6.log.step(pc5.bold("Step 5: Access control"));
587
+ const users = await p6.text({
588
+ message: "Allowed GitHub usernames (comma-separated, leave empty for unrestricted):",
589
+ placeholder: "alice,bob",
590
+ defaultValue: ""
591
+ });
592
+ if (isCancel(users)) cancelled();
593
+ const raw = users ?? "";
594
+ const trimmed = raw.split(",").map((u) => u.trim()).filter(Boolean).join(",");
595
+ if (!trimmed) {
596
+ p6.log.warn(
597
+ "No user restrictions set \u2014 anyone with your GitHub OAuth App callback URL\n can log in to the dashboard. You can restrict this later in .env (ALLOWED_USERS)."
598
+ );
599
+ } else {
600
+ p6.log.info(`${pc5.dim("Allowed users:")} ${trimmed}`);
601
+ }
602
+ return trimmed;
603
+ }
604
+
605
+ // src/steps/networking.ts
606
+ import * as p7 from "@clack/prompts";
607
+ import pc6 from "picocolors";
608
+
609
+ // src/lib/tailscale.ts
610
+ async function isTailscaleInstalled() {
611
+ try {
612
+ await exec("tailscale version");
613
+ return true;
614
+ } catch {
615
+ return false;
616
+ }
617
+ }
618
+ async function installTailscale() {
619
+ switch (process.platform) {
620
+ case "darwin":
621
+ await exec("brew install tailscale");
622
+ break;
623
+ case "linux":
624
+ await exec("curl -fsSL https://tailscale.com/install.sh | sh");
625
+ break;
626
+ default:
627
+ throw new Error("Unsupported platform for auto-install");
628
+ }
629
+ }
630
+ async function isDaemonRunning() {
631
+ try {
632
+ const { stderr } = await exec("tailscale status 2>&1");
633
+ if (stderr.includes("failed to connect")) return false;
634
+ return true;
635
+ } catch (err) {
636
+ const msg = String(err.stderr || err.stdout || err.message || "");
637
+ if (msg.includes("failed to connect")) return false;
638
+ return true;
639
+ }
640
+ }
641
+ async function findTailscaled() {
642
+ const candidates = [
643
+ "/opt/homebrew/opt/tailscale/bin/tailscaled",
644
+ "/usr/local/opt/tailscale/bin/tailscaled"
645
+ ];
646
+ try {
647
+ const { stdout } = await exec("brew --prefix tailscale");
648
+ const path = `${stdout.trim()}/bin/tailscaled`;
649
+ candidates.unshift(path);
650
+ } catch {
651
+ }
652
+ for (const bin of candidates) {
653
+ try {
654
+ await exec(`test -x ${bin}`);
655
+ return bin;
656
+ } catch {
657
+ }
658
+ }
659
+ return null;
660
+ }
661
+ async function startDaemon() {
662
+ const tried = [];
663
+ if (process.platform === "darwin") {
664
+ tried.push("brew services start tailscale");
665
+ try {
666
+ await exec("brew services start tailscale", { timeout: 1e4 });
667
+ } catch {
668
+ }
669
+ if (await waitForDaemon(5e3)) return tried;
670
+ const bin = await findTailscaled();
671
+ if (bin) {
672
+ const stateDir = `${process.env.HOME}/.local/share/tailscale`;
673
+ const cmd = `${bin} --state=${stateDir}/tailscaled.state --tun=userspace-networking`;
674
+ tried.push(cmd);
675
+ try {
676
+ await exec(`mkdir -p ${stateDir} && nohup ${cmd} </dev/null >/dev/null 2>&1 & disown`, {
677
+ timeout: 5e3
678
+ });
679
+ } catch {
680
+ }
681
+ if (await waitForDaemon(5e3)) return tried;
682
+ }
683
+ } else if (process.platform === "linux") {
684
+ tried.push("sudo systemctl enable --now tailscaled");
685
+ try {
686
+ await exec("sudo systemctl enable --now tailscaled", { timeout: 1e4 });
687
+ } catch {
688
+ }
689
+ }
690
+ return tried;
691
+ }
692
+ async function waitForDaemon(timeoutMs = 15e3) {
693
+ const start = Date.now();
694
+ while (Date.now() - start < timeoutMs) {
695
+ if (await isDaemonRunning()) return true;
696
+ await new Promise((r) => setTimeout(r, 1e3));
697
+ }
698
+ return false;
699
+ }
700
+ async function isTailscaleUp() {
701
+ try {
702
+ const { stdout } = await exec("tailscale status");
703
+ return !stdout.includes("Tailscale is stopped") && !stdout.includes("not logged in");
704
+ } catch {
705
+ return false;
706
+ }
707
+ }
708
+ function connectTailscale() {
709
+ execLive("tailscale up");
710
+ }
711
+ async function getTailscaleIp() {
712
+ try {
713
+ const { stdout } = await exec("tailscale ip -4");
714
+ return stdout.trim() || null;
715
+ } catch {
716
+ return null;
717
+ }
718
+ }
719
+
720
+ // src/lib/cloudflare.ts
721
+ async function isCloudflaredInstalled() {
722
+ try {
723
+ await exec("cloudflared version");
724
+ return true;
725
+ } catch {
726
+ return false;
727
+ }
728
+ }
729
+ async function installCloudflared() {
730
+ switch (process.platform) {
731
+ case "darwin":
732
+ await exec("brew install cloudflared");
733
+ break;
734
+ case "linux":
735
+ await exec(
736
+ "curl -fsSL https://pkg.cloudflare.com/cloudflared-linux-amd64.deb -o /tmp/cloudflared.deb && sudo dpkg -i /tmp/cloudflared.deb"
737
+ );
738
+ break;
739
+ default:
740
+ throw new Error("Unsupported platform for auto-install");
741
+ }
742
+ }
743
+ async function isLoggedIn() {
744
+ try {
745
+ await exec("cloudflared tunnel list");
746
+ return true;
747
+ } catch {
748
+ return false;
749
+ }
750
+ }
751
+ function loginCloudflared() {
752
+ execLive("cloudflared tunnel login");
753
+ }
754
+ async function tunnelExists(name) {
755
+ try {
756
+ const { stdout } = await exec("cloudflared tunnel list");
757
+ return stdout.includes(name);
758
+ } catch {
759
+ return false;
760
+ }
761
+ }
762
+ async function createTunnel(name) {
763
+ await exec(`cloudflared tunnel create ${name}`);
764
+ }
765
+ async function routeDns(tunnelName, hostname2) {
766
+ await exec(`cloudflared tunnel route dns ${tunnelName} ${hostname2}`);
767
+ }
768
+
769
+ // src/steps/networking.ts
770
+ async function networkingStep(currentAuthUrl) {
771
+ p7.log.step(pc6.bold("Step 6: Networking"));
772
+ const choice = await p7.select({
773
+ message: "How will you expose Clean?",
774
+ options: [
775
+ { value: "localhost", label: "Localhost only", hint: "default, no setup needed" },
776
+ { value: "tailscale", label: "Tailscale", hint: "private mesh network" },
777
+ { value: "cloudflare", label: "Cloudflare Tunnel", hint: "public URL + WAF" },
778
+ { value: "skip", label: "Skip", hint: "I'll configure manually" }
779
+ ]
780
+ });
781
+ if (isCancel(choice)) cancelled();
782
+ let authUrl = currentAuthUrl;
783
+ if (choice === "tailscale") {
784
+ authUrl = await handleTailscale(currentAuthUrl);
785
+ } else if (choice === "cloudflare") {
786
+ authUrl = await handleCloudflare(currentAuthUrl);
787
+ } else if (choice === "skip") {
788
+ p7.log.info("Skipping networking setup. You can configure this later in .env (AUTH_URL).");
789
+ }
790
+ return { authUrl };
791
+ }
792
+ async function handleTailscale(currentAuthUrl) {
793
+ let installed = await isTailscaleInstalled();
794
+ if (!installed) {
795
+ const s = p7.spinner();
796
+ s.start("Installing Tailscale");
797
+ try {
798
+ await installTailscale();
799
+ installed = await isTailscaleInstalled();
800
+ if (installed) {
801
+ s.stop("Tailscale installed");
802
+ } else {
803
+ s.stop("Installation may have failed", 2);
804
+ }
805
+ } catch (err) {
806
+ s.stop("Installation failed", 1);
807
+ p7.log.error(`Could not install Tailscale: ${err.message}`);
808
+ p7.log.info("Continuing with current URL. Install Tailscale manually and update AUTH_URL in .env.");
809
+ return currentAuthUrl;
810
+ }
811
+ }
812
+ let daemonUp = await isDaemonRunning();
813
+ if (!daemonUp) {
814
+ const s = p7.spinner();
815
+ s.start("Starting Tailscale daemon (tailscaled)");
816
+ const tried = await startDaemon();
817
+ daemonUp = await waitForDaemon(1e4);
818
+ if (daemonUp) {
819
+ s.stop("Tailscale daemon running");
820
+ } else {
821
+ s.stop("Automatic start methods exhausted", 2);
822
+ p7.log.warn(
823
+ `Tried: ${tried.map((t) => pc6.dim(t)).join(", ")}
824
+ tailscaled needs to be running before we can continue.`
825
+ );
826
+ while (!daemonUp) {
827
+ const openTerminal = process.platform === "darwin";
828
+ const action = await p7.select({
829
+ message: "tailscaled isn't running yet. What to do?",
830
+ options: [
831
+ ...openTerminal ? [{ value: "open", label: "Open new terminal with sudo tailscaled", hint: "recommended" }] : [],
832
+ { value: "manual", label: "I'll start it myself", hint: "run in another tab" },
833
+ { value: "skip", label: "Skip Tailscale for now" }
834
+ ]
835
+ });
836
+ if (isCancel(action)) cancelled();
837
+ if (action === "open") {
838
+ try {
839
+ const { exec: run } = await import("./exec-OIKPD6DO.js");
840
+ await run(`osascript -e 'tell app "Terminal" to do script "sudo tailscaled"'`);
841
+ p7.log.info("Opened a new Terminal window \u2014 enter your password there.");
842
+ } catch {
843
+ p7.log.warn(
844
+ `Could not open Terminal. Run this in another tab:
845
+ ${pc6.cyan("sudo tailscaled")}`
846
+ );
847
+ }
848
+ const s2 = p7.spinner();
849
+ s2.start("Waiting for tailscaled to start (30s)");
850
+ daemonUp = await waitForDaemon(3e4);
851
+ if (daemonUp) {
852
+ s2.stop("Tailscale daemon running");
853
+ } else {
854
+ s2.stop("Still not responding", 2);
855
+ }
856
+ } else if (action === "manual") {
857
+ p7.log.info(
858
+ `In another terminal tab, run:
859
+ ${pc6.cyan("sudo tailscaled")}
860
+ Then come back here.`
861
+ );
862
+ const s2 = p7.spinner();
863
+ s2.start("Waiting for tailscaled (30s)");
864
+ daemonUp = await waitForDaemon(3e4);
865
+ if (daemonUp) {
866
+ s2.stop("Tailscale daemon detected");
867
+ } else {
868
+ s2.stop("Still not detected", 2);
869
+ }
870
+ } else {
871
+ p7.log.info("Skipping Tailscale. Update AUTH_URL in .env later.");
872
+ return currentAuthUrl;
873
+ }
874
+ }
875
+ }
876
+ }
877
+ const connected = await isTailscaleUp();
878
+ if (!connected) {
879
+ p7.log.message(
880
+ `Tailscale needs to authenticate. This will open your browser.
881
+ Press Enter to continue, then complete auth in the browser.`
882
+ );
883
+ const proceed = await p7.confirm({
884
+ message: "Run tailscale up now?",
885
+ initialValue: true
886
+ });
887
+ if (isCancel(proceed)) cancelled();
888
+ if (proceed) {
889
+ try {
890
+ p7.log.info(pc6.dim("Running tailscale up... (complete auth in browser)"));
891
+ connectTailscale();
892
+ } catch (err) {
893
+ p7.log.error(`tailscale up failed: ${err.message}`);
894
+ p7.log.info("Continuing with current URL. Run `tailscale up` manually and update AUTH_URL in .env.");
895
+ return currentAuthUrl;
896
+ }
897
+ const nowUp = await isTailscaleUp();
898
+ if (!nowUp) {
899
+ p7.log.warn("Tailscale doesn't appear connected yet. Continuing with current URL.");
900
+ return currentAuthUrl;
901
+ }
902
+ } else {
903
+ p7.log.info("Continuing with current URL. Run `tailscale up` later and update AUTH_URL in .env.");
904
+ return currentAuthUrl;
905
+ }
906
+ }
907
+ const ip = await getTailscaleIp();
908
+ if (!ip) {
909
+ p7.log.warn("Could not detect Tailscale IP. Continuing with current URL.");
910
+ return currentAuthUrl;
911
+ }
912
+ const tsUrl = `http://${ip}:3000`;
913
+ p7.log.success(`Tailscale connected \u2014 IP: ${pc6.bold(ip)}`);
914
+ const useIt = await p7.confirm({
915
+ message: `Use ${pc6.cyan(tsUrl)} as the dashboard URL?`,
916
+ initialValue: true
917
+ });
918
+ if (isCancel(useIt)) cancelled();
919
+ if (useIt) {
920
+ p7.log.warn(
921
+ `Update your GitHub OAuth App callback URL to:
922
+ ${pc6.cyan(`${tsUrl}/api/auth/callback/github`)}`
923
+ );
924
+ return tsUrl;
925
+ }
926
+ return currentAuthUrl;
927
+ }
928
+ async function handleCloudflare(currentAuthUrl) {
929
+ let installed = await isCloudflaredInstalled();
930
+ if (!installed) {
931
+ const s2 = p7.spinner();
932
+ s2.start("Installing cloudflared");
933
+ try {
934
+ await installCloudflared();
935
+ installed = await isCloudflaredInstalled();
936
+ if (installed) {
937
+ s2.stop("cloudflared installed");
938
+ } else {
939
+ s2.stop("Installation may have failed", 2);
940
+ }
941
+ } catch (err) {
942
+ s2.stop("Installation failed", 1);
943
+ p7.log.error(`Could not install cloudflared: ${err.message}`);
944
+ p7.log.info("Continuing with current URL. Install cloudflared manually and update AUTH_URL in .env.");
945
+ return currentAuthUrl;
946
+ }
947
+ }
948
+ const loggedIn = await isLoggedIn();
949
+ if (!loggedIn) {
950
+ p7.log.message("cloudflared needs to authenticate. This will open your browser.");
951
+ const proceed = await p7.confirm({
952
+ message: "Run cloudflared tunnel login now?",
953
+ initialValue: true
954
+ });
955
+ if (isCancel(proceed)) cancelled();
956
+ if (proceed) {
957
+ try {
958
+ p7.log.info(pc6.dim("Running cloudflared tunnel login... (complete auth in browser)"));
959
+ loginCloudflared();
960
+ } catch (err) {
961
+ p7.log.error(`Login failed: ${err.message}`);
962
+ p7.log.info("Continuing with current URL. Run `cloudflared tunnel login` manually.");
963
+ return currentAuthUrl;
964
+ }
965
+ if (!await isLoggedIn()) {
966
+ p7.log.warn("cloudflared doesn't appear authenticated. Continuing with current URL.");
967
+ return currentAuthUrl;
968
+ }
969
+ } else {
970
+ p7.log.info("Continuing with current URL. Run `cloudflared tunnel login` later.");
971
+ return currentAuthUrl;
972
+ }
973
+ }
974
+ const tunnelName = await p7.text({
975
+ message: "Tunnel name:",
976
+ placeholder: "clean",
977
+ initialValue: "clean",
978
+ validate(value) {
979
+ if (!value.trim()) return "Tunnel name is required";
980
+ if (!/^[a-zA-Z0-9-]+$/.test(value)) return "Letters, numbers, and hyphens only";
981
+ }
982
+ });
983
+ if (isCancel(tunnelName)) cancelled();
984
+ const name = tunnelName;
985
+ const exists = await tunnelExists(name);
986
+ if (!exists) {
987
+ const s2 = p7.spinner();
988
+ s2.start(`Creating tunnel "${name}"`);
989
+ try {
990
+ await createTunnel(name);
991
+ s2.stop(`Tunnel "${name}" created`);
992
+ } catch (err) {
993
+ s2.stop("Tunnel creation failed", 1);
994
+ p7.log.error(`Failed: ${err.message}`);
995
+ p7.log.info("Continuing with current URL. Create the tunnel manually.");
996
+ return currentAuthUrl;
997
+ }
998
+ } else {
999
+ p7.log.info(`Using existing tunnel "${name}"`);
1000
+ }
1001
+ const hostname2 = await p7.text({
1002
+ message: "Public hostname for the dashboard (e.g., clean.example.com):",
1003
+ validate(value) {
1004
+ if (!value.trim()) return "Hostname is required";
1005
+ if (!value.includes(".")) return "Needs to be a domain (e.g., clean.example.com)";
1006
+ }
1007
+ });
1008
+ if (isCancel(hostname2)) cancelled();
1009
+ const host = hostname2;
1010
+ const tunnelUrl = `https://${host}`;
1011
+ const s = p7.spinner();
1012
+ s.start(`Routing ${host} to tunnel "${name}"`);
1013
+ try {
1014
+ await routeDns(name, host);
1015
+ s.stop("DNS route created");
1016
+ } catch (err) {
1017
+ s.stop("DNS routing failed", 2);
1018
+ p7.log.warn(
1019
+ `Could not auto-route DNS: ${err.message}
1020
+ You may need to run: ${pc6.cyan(`cloudflared tunnel route dns ${name} ${host}`)}`
1021
+ );
1022
+ }
1023
+ p7.log.success(`Tunnel configured \u2014 ${pc6.cyan(tunnelUrl)}`);
1024
+ p7.log.warn(
1025
+ `Update your GitHub OAuth App callback URL to:
1026
+ ${pc6.cyan(`${tunnelUrl}/api/auth/callback/github`)}`
1027
+ );
1028
+ p7.log.info(
1029
+ `Start the tunnel with: ${pc6.cyan(`cloudflared tunnel run ${name}`)}
1030
+ Or add it as a service: ${pc6.cyan(`sudo cloudflared service install`)}`
1031
+ );
1032
+ return tunnelUrl;
1033
+ }
1034
+
1035
+ // src/steps/generate.ts
1036
+ import * as p8 from "@clack/prompts";
1037
+ import pc7 from "picocolors";
1038
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs";
1039
+ import { join } from "path";
1040
+
1041
+ // src/lib/secrets.ts
1042
+ import { randomBytes } from "crypto";
1043
+ var generateHex = (bytes) => randomBytes(bytes).toString("hex");
1044
+ var generateBase64 = (bytes) => randomBytes(bytes).toString("base64");
1045
+
1046
+ // src/lib/templates.ts
1047
+ function generateDockerCompose(vars) {
1048
+ const plat = vars.needsEmulation ? "\n platform: linux/amd64" : "";
1049
+ return `services:
1050
+ db:
1051
+ image: postgres:16-alpine
1052
+ volumes:
1053
+ - pgdata:/var/lib/postgresql/data
1054
+ environment:
1055
+ POSTGRES_USER: clean
1056
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
1057
+ POSTGRES_DB: clean
1058
+ healthcheck:
1059
+ test: ["CMD-SHELL", "pg_isready -U clean"]
1060
+ interval: 5s
1061
+ timeout: 3s
1062
+ retries: 5
1063
+ restart: unless-stopped
1064
+
1065
+ clean:
1066
+ image: tryclean/clean:\${CLEAN_VERSION:-latest}${plat}
1067
+ ports:
1068
+ - "\${CLEAN_PORT:-8000}:8000"
1069
+ volumes:
1070
+ - clean-data:/data/clean
1071
+ environment:
1072
+ - DATABASE_URL=postgresql://clean:\${POSTGRES_PASSWORD}@db:5432/clean
1073
+ - CLEAN_API_KEY=\${CLEAN_API_KEY}
1074
+ - CLEAN_LICENSE_KEY=\${CLEAN_LICENSE_KEY}
1075
+ env_file:
1076
+ - path: .env
1077
+ required: false
1078
+ depends_on:
1079
+ db:
1080
+ condition: service_healthy
1081
+ restart: unless-stopped
1082
+
1083
+ dashboard:
1084
+ image: tryclean/dashboard:\${CLEAN_VERSION:-latest}${plat}
1085
+ ports:
1086
+ - "\${DASHBOARD_PORT:-3000}:3000"
1087
+ environment:
1088
+ - DATABASE_URL=postgresql://clean:\${POSTGRES_PASSWORD}@db:5432/clean
1089
+ - CLEAN_SERVER_URL=http://clean:8000
1090
+ - CLEAN_API_KEY=\${CLEAN_API_KEY}
1091
+ - AUTH_SECRET=\${AUTH_SECRET}
1092
+ - AUTH_URL=\${AUTH_URL}
1093
+ - GITHUB_ID=\${GITHUB_ID}
1094
+ - GITHUB_SECRET=\${GITHUB_SECRET}
1095
+ - ALLOWED_USERS=\${ALLOWED_USERS:-}
1096
+ depends_on:
1097
+ db:
1098
+ condition: service_healthy
1099
+ clean:
1100
+ condition: service_started
1101
+ restart: unless-stopped
1102
+
1103
+ volumes:
1104
+ clean-data:
1105
+ pgdata:
1106
+ `;
1107
+ }
1108
+ function generateEnvFile(vars) {
1109
+ return `# === Required (auto-generated by create-clean) ===
1110
+
1111
+ # Postgres password (used by all services)
1112
+ POSTGRES_PASSWORD=${vars.postgresPassword}
1113
+
1114
+ # License key \u2014 JWT issued by Clean team
1115
+ CLEAN_LICENSE_KEY=${vars.cleanLicenseKey}
1116
+
1117
+ # API key \u2014 used by dashboard and AI tools to authenticate with the engine
1118
+ CLEAN_API_KEY=${vars.cleanApiKey}
1119
+
1120
+ # GitHub OAuth App credentials (for dashboard login)
1121
+ GITHUB_ID=${vars.githubId}
1122
+ GITHUB_SECRET=${vars.githubSecret}
1123
+
1124
+ # NextAuth session encryption secret
1125
+ AUTH_SECRET=${vars.authSecret}
1126
+
1127
+ # Dashboard public URL
1128
+ AUTH_URL=${vars.authUrl}
1129
+
1130
+ # === Optional ===
1131
+
1132
+ # Pin image version (default: latest)
1133
+ CLEAN_VERSION=${vars.cleanVersion}
1134
+
1135
+ # Restrict dashboard login to specific GitHub usernames (comma-separated)
1136
+ ALLOWED_USERS=${vars.allowedUsers}
1137
+
1138
+ # Custom ports (defaults: engine=8000, dashboard=3000)
1139
+ CLEAN_PORT=${vars.cleanPort}
1140
+ DASHBOARD_PORT=${vars.dashboardPort}
1141
+ `;
1142
+ }
1143
+
1144
+ // src/steps/generate.ts
1145
+ async function generateStep(input) {
1146
+ p8.log.step(pc7.bold("Step 7: Generate configuration"));
1147
+ const dirName = await p8.text({
1148
+ message: "Installation directory:",
1149
+ placeholder: "./clean",
1150
+ initialValue: "./clean",
1151
+ validate(value) {
1152
+ if (!value.trim()) return "Directory name is required";
1153
+ }
1154
+ });
1155
+ if (isCancel(dirName)) cancelled();
1156
+ const installDir = dirName;
1157
+ if (existsSync(installDir)) {
1158
+ const composeFile = join(installDir, "docker-compose.yml");
1159
+ const hasExisting = existsSync(composeFile);
1160
+ if (hasExisting) {
1161
+ let running = false;
1162
+ try {
1163
+ const { stdout } = await exec("docker compose ps -q", { cwd: installDir });
1164
+ running = stdout.trim().length > 0;
1165
+ } catch {
1166
+ }
1167
+ const action = await p8.select({
1168
+ message: `Existing Clean installation found in ${installDir}`,
1169
+ options: [
1170
+ ...running ? [{ value: "teardown", label: "Stop existing services and overwrite", hint: "recommended for fresh install" }] : [],
1171
+ { value: "overwrite", label: "Overwrite config files only", hint: "keeps data volumes" },
1172
+ { value: "different", label: "Use a different directory" },
1173
+ { value: "cancel", label: "Cancel setup" }
1174
+ ]
1175
+ });
1176
+ if (isCancel(action)) cancelled();
1177
+ if (action === "teardown") {
1178
+ const s2 = p8.spinner();
1179
+ s2.start("Stopping existing services and removing volumes");
1180
+ try {
1181
+ await exec("docker compose down -v", { cwd: installDir });
1182
+ s2.stop("Services stopped, volumes removed");
1183
+ } catch {
1184
+ s2.stop("Could not stop services", 2);
1185
+ p8.log.warn("Continuing anyway \u2014 old containers may conflict.");
1186
+ }
1187
+ } else if (action === "different") {
1188
+ return generateStep(input);
1189
+ } else if (action === "cancel") {
1190
+ cancelled();
1191
+ }
1192
+ }
1193
+ }
1194
+ const existingEnvPath = join(installDir, ".env");
1195
+ let existingPgPassword = null;
1196
+ if (existsSync(existingEnvPath)) {
1197
+ try {
1198
+ const envContent = readFileSync(existingEnvPath, "utf-8");
1199
+ const match = envContent.match(/^POSTGRES_PASSWORD=(.+)$/m);
1200
+ if (match?.[1]) existingPgPassword = match[1];
1201
+ } catch {
1202
+ }
1203
+ }
1204
+ const s = p8.spinner();
1205
+ s.start("Generating secrets and config files");
1206
+ const vars = {
1207
+ postgresPassword: existingPgPassword ?? generateHex(16),
1208
+ cleanApiKey: generateHex(32),
1209
+ cleanLicenseKey: input.licenseKey,
1210
+ authSecret: generateBase64(32),
1211
+ authUrl: input.authUrl,
1212
+ githubId: input.githubId,
1213
+ githubSecret: input.githubSecret,
1214
+ allowedUsers: input.allowedUsers,
1215
+ cleanVersion: "latest",
1216
+ cleanPort: 8e3,
1217
+ dashboardPort: 3e3,
1218
+ needsEmulation: input.needsEmulation
1219
+ };
1220
+ mkdirSync(installDir, { recursive: true });
1221
+ const composePath = join(installDir, "docker-compose.yml");
1222
+ const envPath = join(installDir, ".env");
1223
+ writeFileSync(composePath, generateDockerCompose(vars), "utf-8");
1224
+ writeFileSync(envPath, generateEnvFile(vars), "utf-8");
1225
+ s.stop("Config files written");
1226
+ p8.log.info(
1227
+ `${pc7.dim("Created:")} ${composePath}
1228
+ ${pc7.dim("Created:")} ${envPath}`
1229
+ );
1230
+ p8.log.success("Configuration generated");
1231
+ return { installDir, vars };
1232
+ }
1233
+
1234
+ // src/steps/launch.ts
1235
+ import * as p9 from "@clack/prompts";
1236
+ import pc8 from "picocolors";
1237
+ var PORTS = [8e3, 3e3];
1238
+ async function launchStep(installDir) {
1239
+ p9.log.step(pc8.bold("Step 8: Launch services"));
1240
+ await cleanupBeforeStart(installDir);
1241
+ await pullWithRetry(installDir);
1242
+ await startWithRetry(installDir);
1243
+ p9.log.success("All services launched");
1244
+ }
1245
+ async function cleanupBeforeStart(installDir) {
1246
+ try {
1247
+ await exec("docker compose down", { cwd: installDir });
1248
+ } catch {
1249
+ }
1250
+ for (const port of PORTS) {
1251
+ const containers = await getDockerContainerOnPort(port);
1252
+ if (containers.length > 0) {
1253
+ p9.log.info(
1254
+ `Stopping container ${pc8.bold(containers.join(", "))} on port ${port}`
1255
+ );
1256
+ for (const name of containers) {
1257
+ try {
1258
+ await exec(`docker stop ${name}`);
1259
+ } catch {
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+ await new Promise((r) => setTimeout(r, 500));
1265
+ }
1266
+ async function pullWithRetry(installDir, attempts = 0) {
1267
+ p9.log.info(pc8.dim("Pulling Docker images...\n"));
1268
+ try {
1269
+ await composePullLive(installDir);
1270
+ console.log();
1271
+ p9.log.success("Images pulled");
1272
+ } catch (err) {
1273
+ console.log();
1274
+ if (attempts >= 3) {
1275
+ p9.log.error(`Failed to pull images after ${attempts + 1} attempts.`);
1276
+ process.exit(1);
1277
+ }
1278
+ p9.log.error("Pull failed");
1279
+ const action = await p9.select({
1280
+ message: "How to proceed?",
1281
+ options: [
1282
+ { value: "retry", label: "Retry pull" },
1283
+ { value: "skip", label: "Skip pull", hint: "use cached images if available" },
1284
+ { value: "cancel", label: "Cancel setup" }
1285
+ ]
1286
+ });
1287
+ if (isCancel(action)) cancelled();
1288
+ if (action === "retry") return pullWithRetry(installDir, attempts + 1);
1289
+ if (action === "cancel") cancelled();
1290
+ }
1291
+ }
1292
+ async function startWithRetry(installDir, attempts = 0) {
1293
+ const s = p9.spinner();
1294
+ s.start("Starting services");
1295
+ try {
1296
+ await composeUp(installDir);
1297
+ s.stop("Services started");
1298
+ } catch (err) {
1299
+ s.stop("Start failed", 1);
1300
+ if (attempts >= 3) {
1301
+ p9.log.error(`Failed to start services after ${attempts + 1} attempts.`);
1302
+ await showLogs(installDir);
1303
+ process.exit(1);
1304
+ }
1305
+ const errMsg = err.message;
1306
+ const portMatch = errMsg.match(/Bind for [\d.]+:(\d+) failed: port is already allocated/);
1307
+ if (portMatch) {
1308
+ const port = parseInt(portMatch[1], 10);
1309
+ const containers = await getDockerContainerOnPort(port);
1310
+ if (containers.length > 0) {
1311
+ p9.log.info(`Stopping container ${pc8.bold(containers.join(", "))} on port ${port}`);
1312
+ for (const name of containers) {
1313
+ try {
1314
+ await exec(`docker stop ${name}`);
1315
+ } catch {
1316
+ }
1317
+ }
1318
+ await new Promise((r) => setTimeout(r, 1e3));
1319
+ p9.log.success(`Port ${port} freed`);
1320
+ return startWithRetry(installDir, attempts + 1);
1321
+ }
1322
+ const proc = await getProcessOnPort(port);
1323
+ const procInfo = proc ? `${pc8.bold(proc.command)} (PID ${proc.pid})` : "unknown process";
1324
+ p9.log.warn(`Port ${pc8.bold(String(port))} is in use by ${procInfo}`);
1325
+ const action2 = await p9.select({
1326
+ message: `Kill ${proc?.command ?? "process"} on port ${port} and retry?`,
1327
+ options: [
1328
+ { value: "kill", label: `Kill and retry`, hint: "recommended" },
1329
+ { value: "cancel", label: "Cancel setup" }
1330
+ ]
1331
+ });
1332
+ if (isCancel(action2)) cancelled();
1333
+ if (action2 === "kill") {
1334
+ await killProcessOnPort(port);
1335
+ const free = await checkPort(port);
1336
+ if (free) p9.log.success(`Port ${port} freed`);
1337
+ return startWithRetry(installDir, attempts + 1);
1338
+ }
1339
+ cancelled();
1340
+ }
1341
+ p9.log.warn(`Failed to start: ${errMsg}`);
1342
+ await showLogs(installDir);
1343
+ const action = await p9.select({
1344
+ message: "How to proceed?",
1345
+ options: [
1346
+ { value: "retry", label: "Retry start" },
1347
+ { value: "cancel", label: "Cancel setup" }
1348
+ ]
1349
+ });
1350
+ if (isCancel(action)) cancelled();
1351
+ if (action === "retry") return startWithRetry(installDir, attempts + 1);
1352
+ cancelled();
1353
+ }
1354
+ }
1355
+ async function showLogs(installDir) {
1356
+ try {
1357
+ const { stdout } = await exec(`docker compose logs --tail 20 2>&1`, { cwd: installDir });
1358
+ if (stdout.trim()) {
1359
+ p9.log.info(`${pc8.dim("Recent logs:")}
1360
+ ${stdout.trim()}`);
1361
+ }
1362
+ } catch {
1363
+ }
1364
+ }
1365
+
1366
+ // src/steps/health.ts
1367
+ import * as p10 from "@clack/prompts";
1368
+ import pc9 from "picocolors";
1369
+ async function pollHealth(target) {
1370
+ const start = Date.now();
1371
+ const interval = 2e3;
1372
+ while (Date.now() - start < target.timeoutMs) {
1373
+ try {
1374
+ const resp = await fetch(target.url, { signal: AbortSignal.timeout(3e3) });
1375
+ if (resp.ok) return true;
1376
+ } catch {
1377
+ }
1378
+ await new Promise((r) => setTimeout(r, interval));
1379
+ }
1380
+ return false;
1381
+ }
1382
+ async function getServiceLogs(installDir, service) {
1383
+ try {
1384
+ const { stdout } = await exec(`docker compose logs --tail 15 ${service} 2>&1`, {
1385
+ cwd: installDir
1386
+ });
1387
+ return stdout.trim();
1388
+ } catch {
1389
+ return "";
1390
+ }
1391
+ }
1392
+ async function healthStep(installDir, enginePort = 8e3, dashboardPort = 3e3) {
1393
+ p10.log.step(pc9.bold("Step 9: Health checks"));
1394
+ const targets = [
1395
+ { name: "Engine", service: "clean", url: `http://localhost:${enginePort}/health`, timeoutMs: 12e4 },
1396
+ { name: "Dashboard", service: "dashboard", url: `http://localhost:${dashboardPort}`, timeoutMs: 6e4 }
1397
+ ];
1398
+ for (const target of targets) {
1399
+ await checkService(installDir, target);
1400
+ }
1401
+ p10.log.success("Health checks complete");
1402
+ }
1403
+ async function checkService(installDir, target) {
1404
+ const s = p10.spinner();
1405
+ const timeoutSec = Math.round(target.timeoutMs / 1e3);
1406
+ s.start(`Waiting for ${target.name} (timeout: ${timeoutSec}s)`);
1407
+ const healthy = await pollHealth(target);
1408
+ if (healthy) {
1409
+ s.stop(`${target.name} \u2014 healthy`);
1410
+ return;
1411
+ }
1412
+ s.stop(`${target.name} \u2014 not responding`, 2);
1413
+ const logs2 = await getServiceLogs(installDir, target.service);
1414
+ if (logs2) {
1415
+ p10.log.info(`${pc9.dim(`${target.name} logs:`)}
1416
+ ${logs2}`);
1417
+ }
1418
+ const action = await p10.select({
1419
+ message: `${target.name} didn't become healthy. What to do?`,
1420
+ options: [
1421
+ { value: "restart", label: `Restart ${target.name}`, hint: "recommended" },
1422
+ { value: "wait", label: "Wait longer", hint: `another ${timeoutSec}s` },
1423
+ { value: "skip", label: "Skip", hint: "check manually later" }
1424
+ ]
1425
+ });
1426
+ if (isCancel(action)) cancelled();
1427
+ if (action === "restart") {
1428
+ const rs = p10.spinner();
1429
+ rs.start(`Restarting ${target.service}`);
1430
+ try {
1431
+ await exec(`docker compose restart ${target.service}`, { cwd: installDir });
1432
+ rs.stop(`${target.service} restarted`);
1433
+ } catch {
1434
+ rs.stop("Restart failed", 1);
1435
+ }
1436
+ const s2 = p10.spinner();
1437
+ s2.start(`Waiting for ${target.name} after restart`);
1438
+ const ok = await pollHealth(target);
1439
+ if (ok) {
1440
+ s2.stop(`${target.name} \u2014 healthy`);
1441
+ } else {
1442
+ s2.stop(`${target.name} \u2014 still not responding`, 2);
1443
+ p10.log.warn(
1444
+ `Check manually with: ${pc9.cyan(`docker compose logs ${target.service}`)}`
1445
+ );
1446
+ }
1447
+ } else if (action === "wait") {
1448
+ const s2 = p10.spinner();
1449
+ s2.start(`Waiting for ${target.name} (another ${timeoutSec}s)`);
1450
+ const ok = await pollHealth(target);
1451
+ if (ok) {
1452
+ s2.stop(`${target.name} \u2014 healthy`);
1453
+ } else {
1454
+ s2.stop(`${target.name} \u2014 still not responding`, 2);
1455
+ p10.log.warn(
1456
+ `Check manually with: ${pc9.cyan(`docker compose logs ${target.service}`)}`
1457
+ );
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ // src/commands/setup.ts
1463
+ async function setup() {
1464
+ banner();
1465
+ p11.intro(pc10.bgCyan(pc10.black(" Clean Setup Wizard ")));
1466
+ const { needsEmulation } = await preflight();
1467
+ const { licenseKey } = await licenseStep();
1468
+ let authUrl = await dashboardUrlStep();
1469
+ const { githubId, githubSecret } = await githubOauthStep(authUrl);
1470
+ const allowedUsers = await accessControlStep();
1471
+ const networking = await networkingStep(authUrl);
1472
+ authUrl = networking.authUrl;
1473
+ const { installDir, vars } = await generateStep({
1474
+ licenseKey,
1475
+ authUrl,
1476
+ githubId,
1477
+ githubSecret,
1478
+ allowedUsers,
1479
+ needsEmulation
1480
+ });
1481
+ await launchStep(installDir);
1482
+ await healthStep(installDir, vars.cleanPort, vars.dashboardPort);
1483
+ const engineUrl = `http://localhost:${vars.cleanPort}`;
1484
+ const dashboardUrl = authUrl;
1485
+ const sseUrl = `${engineUrl}/mcp/sse`;
1486
+ p11.note(
1487
+ `${pc10.bold("Dashboard:")} ${pc10.cyan(dashboardUrl)}
1488
+ ${pc10.bold("Engine:")} ${pc10.cyan(engineUrl)}
1489
+ ${pc10.bold("API Key:")} ${pc10.dim(vars.cleanApiKey.slice(0, 8) + "..." + vars.cleanApiKey.slice(-4))}
1490
+
1491
+ ${pc10.bold("MCP endpoint:")} ${pc10.cyan(sseUrl)}
1492
+ Add to Claude Desktop / Cursor to give AI tools code search.
1493
+ Run ${pc10.cyan("npx create-clean connect")} for full setup instructions.
1494
+
1495
+ ${pc10.bold("Management commands:")}
1496
+ ${pc10.cyan("npx create-clean")} \u2014 interactive menu
1497
+ ${pc10.cyan("npx create-clean connect")} \u2014 MCP setup for AI tools
1498
+ ${pc10.cyan("npx create-clean status")} \u2014 check service health
1499
+ ${pc10.cyan("npx create-clean update")} \u2014 pull latest images
1500
+ ${pc10.cyan("npx create-clean logs")} \u2014 tail service logs
1501
+ ${pc10.cyan("npx create-clean backup")} \u2014 backup data`,
1502
+ "Setup complete!"
1503
+ );
1504
+ const openBrowser = await p11.confirm({
1505
+ message: "Open the dashboard in your browser?",
1506
+ initialValue: true
1507
+ });
1508
+ if (!p11.isCancel(openBrowser) && openBrowser) {
1509
+ const { exec: exec2 } = await import("./exec-OIKPD6DO.js");
1510
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1511
+ try {
1512
+ await exec2(`${openCmd} "${dashboardUrl}"`);
1513
+ } catch {
1514
+ }
1515
+ }
1516
+ p11.outro(pc10.green("Happy coding!"));
1517
+ }
1518
+
1519
+ // src/commands/update.ts
1520
+ import * as p12 from "@clack/prompts";
1521
+ import pc11 from "picocolors";
1522
+ async function update() {
1523
+ banner();
1524
+ p12.intro(pc11.bgCyan(pc11.black(" Clean Update ")));
1525
+ const cwd = process.cwd();
1526
+ const s = p12.spinner();
1527
+ p12.log.info(pc11.dim("Pulling latest images...\n"));
1528
+ try {
1529
+ await composePullLive(cwd);
1530
+ console.log();
1531
+ p12.log.success("Images updated");
1532
+ } catch (err) {
1533
+ console.log();
1534
+ p12.log.error(
1535
+ `Failed to pull images: ${err.message}
1536
+ Make sure you're in the Clean installation directory (contains docker-compose.yml).`
1537
+ );
1538
+ process.exit(1);
1539
+ }
1540
+ s.start("Restarting services");
1541
+ try {
1542
+ await composeUp(cwd);
1543
+ s.stop("Services restarted");
1544
+ } catch (err) {
1545
+ s.stop("Restart failed", 1);
1546
+ p12.log.error(`Failed to restart: ${err.message}`);
1547
+ process.exit(1);
1548
+ }
1549
+ p12.outro(pc11.green("Update complete!"));
1550
+ }
1551
+
1552
+ // src/commands/status.ts
1553
+ import * as p13 from "@clack/prompts";
1554
+ import pc12 from "picocolors";
1555
+ var SERVICES = [
1556
+ { name: "Engine", url: "http://localhost:8000/health" },
1557
+ { name: "Dashboard", url: "http://localhost:3000" }
1558
+ ];
1559
+ async function checkHealth(svc) {
1560
+ try {
1561
+ const resp = await fetch(svc.url, { signal: AbortSignal.timeout(5e3) });
1562
+ return { name: svc.name, ok: resp.ok, status: `${resp.status} ${resp.statusText}` };
1563
+ } catch (err) {
1564
+ return { name: svc.name, ok: false, status: err.message };
1565
+ }
1566
+ }
1567
+ async function status() {
1568
+ banner();
1569
+ p13.intro(pc12.bgCyan(pc12.black(" Clean Status ")));
1570
+ const cwd = process.cwd();
1571
+ try {
1572
+ const ps = await composePs(cwd);
1573
+ p13.log.info(`${pc12.bold("Docker Compose:")}
1574
+ ${ps}`);
1575
+ } catch {
1576
+ p13.log.warn(
1577
+ "Could not get container status. Make sure you're in the Clean installation directory."
1578
+ );
1579
+ }
1580
+ const s = p13.spinner();
1581
+ s.start("Checking service health");
1582
+ const results = await Promise.all(SERVICES.map(checkHealth));
1583
+ s.stop("Health check complete");
1584
+ for (const r of results) {
1585
+ const icon = r.ok ? pc12.green("healthy") : pc12.red("unhealthy");
1586
+ p13.log.info(`${pc12.bold(r.name.padEnd(12))} ${icon} ${pc12.dim(r.status)}`);
1587
+ }
1588
+ p13.outro(results.every((r) => r.ok) ? pc12.green("All services healthy") : pc12.yellow("Some services need attention"));
1589
+ }
1590
+
1591
+ // src/commands/logs.ts
1592
+ import pc13 from "picocolors";
1593
+ function logs() {
1594
+ banner();
1595
+ console.log(pc13.dim(" Tailing docker compose logs (Ctrl+C to stop)\n"));
1596
+ try {
1597
+ execLive("docker compose logs -f --tail 100", { cwd: process.cwd() });
1598
+ } catch {
1599
+ console.error(
1600
+ pc13.red("Failed to tail logs.") + "\n Make sure you're in the Clean installation directory."
1601
+ );
1602
+ process.exit(1);
1603
+ }
1604
+ }
1605
+
1606
+ // src/commands/backup.ts
1607
+ import * as p14 from "@clack/prompts";
1608
+ import pc14 from "picocolors";
1609
+ import { mkdirSync as mkdirSync2 } from "fs";
1610
+ import { join as join2 } from "path";
1611
+ async function backup() {
1612
+ banner();
1613
+ p14.intro(pc14.bgCyan(pc14.black(" Clean Backup ")));
1614
+ const cwd = process.cwd();
1615
+ const date = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1616
+ const backupDir = join2(cwd, `backup-${date}`);
1617
+ mkdirSync2(backupDir, { recursive: true });
1618
+ const s = p14.spinner();
1619
+ s.start("Backing up PostgreSQL database");
1620
+ const sqlFile = join2(backupDir, "clean.sql");
1621
+ try {
1622
+ const dump = await composeExec(cwd, "db", "pg_dump -U clean clean");
1623
+ const { writeFileSync: writeFileSync2 } = await import("fs");
1624
+ writeFileSync2(sqlFile, dump, "utf-8");
1625
+ s.stop(`Database backup saved`);
1626
+ } catch (err) {
1627
+ s.stop("Database backup failed", 1);
1628
+ p14.log.error(`Failed to dump database: ${err.message}`);
1629
+ }
1630
+ s.start("Backing up vector data");
1631
+ const dataDir = join2(backupDir, "clean-data");
1632
+ mkdirSync2(dataDir, { recursive: true });
1633
+ try {
1634
+ await composeCp(cwd, "clean:/data/clean/.", dataDir);
1635
+ s.stop("Vector data backup saved");
1636
+ } catch (err) {
1637
+ s.stop("Vector data backup failed", 1);
1638
+ p14.log.error(`Failed to copy vector data: ${err.message}`);
1639
+ }
1640
+ try {
1641
+ const { stdout } = await exec(`du -sh "${backupDir}"`);
1642
+ const size = stdout.split(" ")[0];
1643
+ p14.log.info(`${pc14.dim("Location:")} ${backupDir}
1644
+ ${pc14.dim("Size:")} ${size}`);
1645
+ } catch {
1646
+ p14.log.info(`${pc14.dim("Location:")} ${backupDir}`);
1647
+ }
1648
+ p14.outro(pc14.green("Backup complete!"));
1649
+ }
1650
+
1651
+ // src/commands/connect.ts
1652
+ import * as p15 from "@clack/prompts";
1653
+ import pc15 from "picocolors";
1654
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1655
+ import { join as join3 } from "path";
1656
+ function readEnvVar(envPath, key) {
1657
+ try {
1658
+ const content = readFileSync2(envPath, "utf-8");
1659
+ const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
1660
+ return match?.[1] ?? null;
1661
+ } catch {
1662
+ return null;
1663
+ }
1664
+ }
1665
+ async function connect() {
1666
+ banner();
1667
+ p15.intro(pc15.bgCyan(pc15.black(" Connect AI Tools ")));
1668
+ const cwd = process.cwd();
1669
+ const envPath = existsSync2(join3(cwd, ".env")) ? join3(cwd, ".env") : join3(cwd, "clean", ".env");
1670
+ if (!existsSync2(envPath)) {
1671
+ p15.log.error(
1672
+ `No Clean installation found.
1673
+ Run ${pc15.cyan("npx create-clean")} first, or cd into your installation directory.`
1674
+ );
1675
+ process.exit(1);
1676
+ }
1677
+ const apiKey = readEnvVar(envPath, "CLEAN_API_KEY") ?? "<your-api-key>";
1678
+ const authUrl = readEnvVar(envPath, "AUTH_URL") ?? "http://localhost:3000";
1679
+ const cleanPort = readEnvVar(envPath, "CLEAN_PORT") ?? "8000";
1680
+ let engineUrl;
1681
+ try {
1682
+ const parsed = new URL(authUrl);
1683
+ parsed.port = cleanPort;
1684
+ engineUrl = parsed.toString().replace(/\/$/, "");
1685
+ } catch {
1686
+ engineUrl = `http://localhost:${cleanPort}`;
1687
+ }
1688
+ const sseUrl = `${engineUrl}/mcp/sse`;
1689
+ const claudeConfig = JSON.stringify(
1690
+ {
1691
+ mcpServers: {
1692
+ clean: {
1693
+ url: sseUrl,
1694
+ headers: {
1695
+ Authorization: `Bearer ${apiKey}`
1696
+ }
1697
+ }
1698
+ }
1699
+ },
1700
+ null,
1701
+ 2
1702
+ );
1703
+ p15.log.step(pc15.bold("MCP Server Connection"));
1704
+ p15.log.info(
1705
+ `${pc15.bold("Endpoint:")} ${pc15.cyan(sseUrl)}
1706
+ ${pc15.bold("API Key:")} ${pc15.dim(apiKey.slice(0, 8) + "..." + apiKey.slice(-4))}`
1707
+ );
1708
+ p15.note(
1709
+ `${pc15.bold("Claude Desktop / Claude Code")}
1710
+ Add to ${pc15.dim("~/.claude/claude_desktop_config.json")}:
1711
+
1712
+ ${pc15.cyan(claudeConfig)}`,
1713
+ "Configuration"
1714
+ );
1715
+ p15.log.info(
1716
+ `${pc15.bold("Cursor / VS Code")}
1717
+ Add the same config to your MCP settings in the editor.
1718
+ Cursor: ${pc15.dim("Settings \u2192 MCP Servers \u2192 Add")}
1719
+ VS Code: ${pc15.dim(".vscode/mcp.json or settings.json")}`
1720
+ );
1721
+ p15.log.info(
1722
+ `${pc15.bold("Dashboard API Keys")}
1723
+ Create scoped API keys with repo restrictions at:
1724
+ ${pc15.cyan(authUrl)}`
1725
+ );
1726
+ const copy = await p15.confirm({
1727
+ message: "Copy MCP config to clipboard?",
1728
+ initialValue: true
1729
+ });
1730
+ if (!p15.isCancel(copy) && copy) {
1731
+ try {
1732
+ const { exec: exec2 } = await import("./exec-OIKPD6DO.js");
1733
+ if (process.platform === "darwin") {
1734
+ await exec2(`echo ${JSON.stringify(claudeConfig)} | pbcopy`);
1735
+ } else {
1736
+ await exec2(`echo ${JSON.stringify(claudeConfig)} | xclip -selection clipboard`);
1737
+ }
1738
+ p15.log.success("Config copied to clipboard");
1739
+ } catch {
1740
+ p15.log.warn("Could not copy to clipboard \u2014 copy the config above manually");
1741
+ }
1742
+ }
1743
+ p15.outro(pc15.green("Connect your AI tools and start searching!"));
1744
+ }
1745
+
1746
+ // src/index.ts
1747
+ var command = process.argv[2];
1748
+ var commands = {
1749
+ update,
1750
+ status,
1751
+ logs,
1752
+ backup,
1753
+ connect
1754
+ };
1755
+ function findInstallation() {
1756
+ const cwd = process.cwd();
1757
+ if (existsSync3(join4(cwd, "docker-compose.yml")) && existsSync3(join4(cwd, ".env"))) {
1758
+ return cwd;
1759
+ }
1760
+ if (existsSync3(join4(cwd, "clean", "docker-compose.yml")) && existsSync3(join4(cwd, "clean", ".env"))) {
1761
+ return join4(cwd, "clean");
1762
+ }
1763
+ return null;
1764
+ }
1765
+ async function smartStart() {
1766
+ const installDir = findInstallation();
1767
+ if (!installDir) {
1768
+ await setup();
1769
+ return;
1770
+ }
1771
+ const p16 = await import("@clack/prompts");
1772
+ const pc16 = (await import("picocolors")).default;
1773
+ const { banner: banner2 } = await import("./ui-6FJJNJ7F.js");
1774
+ banner2();
1775
+ p16.intro(pc16.bgCyan(pc16.black(" Clean ")));
1776
+ p16.log.info(`Existing installation found in ${pc16.bold(installDir)}`);
1777
+ const action = await p16.select({
1778
+ message: "What would you like to do?",
1779
+ options: [
1780
+ { value: "status", label: "Check status", hint: "service health" },
1781
+ { value: "update", label: "Update", hint: "pull latest images + restart" },
1782
+ { value: "connect", label: "Connect AI tools", hint: "MCP setup for Claude, Cursor, etc." },
1783
+ { value: "logs", label: "View logs", hint: "tail docker compose logs" },
1784
+ { value: "backup", label: "Backup data", hint: "postgres + vector data" },
1785
+ { value: "setup", label: "Re-run setup wizard", hint: "fresh install" }
1786
+ ]
1787
+ });
1788
+ if (p16.isCancel(action)) {
1789
+ p16.outro("Bye!");
1790
+ return;
1791
+ }
1792
+ if (action === "setup") {
1793
+ await setup();
1794
+ } else if (action === "connect") {
1795
+ await connect();
1796
+ } else {
1797
+ await commands[action]();
1798
+ }
1799
+ }
1800
+ async function main() {
1801
+ try {
1802
+ if (command && command in commands) {
1803
+ await commands[command]();
1804
+ } else if (command === "--help" || command === "-h" || command === "help") {
1805
+ console.log(`
1806
+ create-clean \u2014 Set up Clean, semantic code search for AI agents
1807
+
1808
+ Usage:
1809
+ npx create-clean Interactive menu (or setup wizard)
1810
+ npx create-clean setup Full setup wizard
1811
+ npx create-clean update Pull latest images + restart
1812
+ npx create-clean status Check service health
1813
+ npx create-clean connect MCP setup for AI tools
1814
+ npx create-clean logs Tail docker compose logs
1815
+ npx create-clean backup Backup postgres + vector data
1816
+ `);
1817
+ } else if (command === "setup") {
1818
+ await setup();
1819
+ } else if (command && command !== "--help") {
1820
+ console.error(`Unknown command: ${command}`);
1821
+ console.error(`Run "create-clean --help" for usage.`);
1822
+ process.exit(1);
1823
+ } else {
1824
+ await smartStart();
1825
+ }
1826
+ } catch (err) {
1827
+ if (err.message?.includes("User force closed")) {
1828
+ console.log("\nCancelled.");
1829
+ process.exit(0);
1830
+ }
1831
+ console.error("Fatal error:", err.message);
1832
+ process.exit(1);
1833
+ }
1834
+ }
1835
+ main();