gitops-ai 1.0.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.
Files changed (47) hide show
  1. package/README.md +155 -0
  2. package/dist/commands/bootstrap.d.ts +2 -0
  3. package/dist/commands/bootstrap.js +721 -0
  4. package/dist/commands/bootstrap.js.map +1 -0
  5. package/dist/commands/sops.d.ts +1 -0
  6. package/dist/commands/sops.js +300 -0
  7. package/dist/commands/sops.js.map +1 -0
  8. package/dist/core/bootstrap-runner.d.ts +13 -0
  9. package/dist/core/bootstrap-runner.js +194 -0
  10. package/dist/core/bootstrap-runner.js.map +1 -0
  11. package/dist/core/dependencies.d.ts +3 -0
  12. package/dist/core/dependencies.js +134 -0
  13. package/dist/core/dependencies.js.map +1 -0
  14. package/dist/core/encryption.d.ts +25 -0
  15. package/dist/core/encryption.js +209 -0
  16. package/dist/core/encryption.js.map +1 -0
  17. package/dist/core/flux.d.ts +6 -0
  18. package/dist/core/flux.js +60 -0
  19. package/dist/core/flux.js.map +1 -0
  20. package/dist/core/gitlab.d.ts +10 -0
  21. package/dist/core/gitlab.js +65 -0
  22. package/dist/core/gitlab.js.map +1 -0
  23. package/dist/core/kubernetes.d.ts +10 -0
  24. package/dist/core/kubernetes.js +81 -0
  25. package/dist/core/kubernetes.js.map +1 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +49 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/schemas.d.ts +50 -0
  30. package/dist/schemas.js +56 -0
  31. package/dist/schemas.js.map +1 -0
  32. package/dist/utils/config.d.ts +3 -0
  33. package/dist/utils/config.js +26 -0
  34. package/dist/utils/config.js.map +1 -0
  35. package/dist/utils/log.d.ts +31 -0
  36. package/dist/utils/log.js +96 -0
  37. package/dist/utils/log.js.map +1 -0
  38. package/dist/utils/platform.d.ts +7 -0
  39. package/dist/utils/platform.js +21 -0
  40. package/dist/utils/platform.js.map +1 -0
  41. package/dist/utils/shell.d.ts +41 -0
  42. package/dist/utils/shell.js +86 -0
  43. package/dist/utils/shell.js.map +1 -0
  44. package/dist/utils/wizard.d.ts +16 -0
  45. package/dist/utils/wizard.js +117 -0
  46. package/dist/utils/wizard.js.map +1 -0
  47. package/package.json +32 -0
@@ -0,0 +1,721 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import { header, log, summary, nextSteps, finish, handleCancel, withSpinner, formatError, } from "../utils/log.js";
6
+ import { saveInstallPlan, loadInstallPlan, clearInstallPlan } from "../utils/config.js";
7
+ import { execAsync, exec, commandExists } from "../utils/shell.js";
8
+ import { isMacOS, isCI } from "../utils/platform.js";
9
+ import { ensureAll } from "../core/dependencies.js";
10
+ import { runBootstrap } from "../core/bootstrap-runner.js";
11
+ import * as flux from "../core/flux.js";
12
+ import * as gitlab from "../core/gitlab.js";
13
+ import { COMPONENTS, REQUIRED_COMPONENT_IDS, DNS_TLS_COMPONENT_IDS, OPTIONAL_COMPONENTS, SOURCE_GITLAB_HOST, SOURCE_PROJECT_PATH, } from "../schemas.js";
14
+ import { stepWizard, back, maskSecret, } from "../utils/wizard.js";
15
+ function isNewRepo(state) {
16
+ return state.setupMode === "new";
17
+ }
18
+ function dnsAndTlsEnabled(state) {
19
+ return state.manageDnsAndTls;
20
+ }
21
+ function openclawEnabled(state) {
22
+ return state.selectedComponents.includes("openclaw");
23
+ }
24
+ function componentLabel(id) {
25
+ return COMPONENTS.find((c) => c.id === id)?.label ?? id;
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Wizard field definitions (Esc / Ctrl+C = go back one field)
29
+ // ---------------------------------------------------------------------------
30
+ function buildFields(detectedIp, hasSavedPlan) {
31
+ const saved = (state, key) => hasSavedPlan && !!state[key];
32
+ return [
33
+ // ── Setup Mode ──────────────────────────────────────────────────────
34
+ {
35
+ id: "setupMode",
36
+ section: "Setup Mode",
37
+ skip: (state) => saved(state, "setupMode"),
38
+ run: async (state) => {
39
+ const v = await p.select({
40
+ message: pc.bold("How would you like to setup your cluster?"),
41
+ options: [
42
+ {
43
+ value: "new",
44
+ label: ("Init a new gitops repo"),
45
+ hint: "clone, push, and bootstrap",
46
+ },
47
+ {
48
+ value: "existing",
49
+ label: "I already have a repo",
50
+ hint: "bootstrap from existing gitops repo",
51
+ },
52
+ ],
53
+ initialValue: state.setupMode,
54
+ });
55
+ if (p.isCancel(v))
56
+ return back();
57
+ return { ...state, setupMode: v };
58
+ },
59
+ review: (state) => [
60
+ "Mode",
61
+ state.setupMode === "new"
62
+ ? "Create new repo from template"
63
+ : "Use existing repo",
64
+ ],
65
+ },
66
+ // ── GitLab Repository ───────────────────────────────────────────────
67
+ {
68
+ id: "gitlabPat",
69
+ section: "GitLab Repository",
70
+ skip: (state) => !!state.gitlabPat,
71
+ run: async (state) => {
72
+ const v = await p.password({
73
+ message: pc.bold("GitLab Personal Access Token (api, read_repository, write_repository)"),
74
+ validate: (v) => {
75
+ if (!v)
76
+ return "Required";
77
+ },
78
+ });
79
+ if (p.isCancel(v))
80
+ return back();
81
+ return { ...state, gitlabPat: v };
82
+ },
83
+ review: (state) => ["PAT", maskSecret(state.gitlabPat)],
84
+ },
85
+ {
86
+ id: "repoOwner",
87
+ section: "GitLab Repository",
88
+ skip: (state) => saved(state, "repoOwner"),
89
+ run: async (state) => {
90
+ const v = await p.text({
91
+ message: pc.bold("GitLab repo owner / namespace (without @)"),
92
+ placeholder: "my-username-or-group",
93
+ initialValue: state.repoOwner || undefined,
94
+ validate: (v) => {
95
+ if (!v)
96
+ return "Required";
97
+ },
98
+ });
99
+ if (p.isCancel(v))
100
+ return back();
101
+ return { ...state, repoOwner: v };
102
+ },
103
+ review: (state) => ["Repo owner", state.repoOwner],
104
+ },
105
+ {
106
+ id: "repoName",
107
+ section: "GitLab Repository",
108
+ skip: (state) => saved(state, "repoName"),
109
+ run: async (state) => {
110
+ const v = await p.text({
111
+ message: isNewRepo(state)
112
+ ? pc.bold("New repository name")
113
+ : pc.bold("Flux GitLab repo name"),
114
+ placeholder: "fluxcd_ai",
115
+ defaultValue: state.repoName,
116
+ });
117
+ if (p.isCancel(v))
118
+ return back();
119
+ return { ...state, repoName: v };
120
+ },
121
+ review: (state) => ["Repo name", state.repoName],
122
+ },
123
+ {
124
+ id: "repoLocalPath",
125
+ section: "GitLab Repository",
126
+ hidden: (state) => !isNewRepo(state),
127
+ skip: (state) => saved(state, "repoLocalPath"),
128
+ run: async (state) => {
129
+ const v = await p.text({
130
+ message: pc.bold("Local directory to clone into"),
131
+ placeholder: `./${state.repoName} (relative to current directory)`,
132
+ defaultValue: state.repoLocalPath || state.repoName,
133
+ });
134
+ if (p.isCancel(v))
135
+ return back();
136
+ return { ...state, repoLocalPath: v };
137
+ },
138
+ review: (state) => ["Local path", `./${state.repoLocalPath}`],
139
+ },
140
+ {
141
+ id: "repoBranch",
142
+ section: "GitLab Repository",
143
+ skip: (state) => saved(state, "repoBranch"),
144
+ run: async (state) => {
145
+ const v = await p.text({
146
+ message: isNewRepo(state)
147
+ ? pc.bold("Template branch name to clone")
148
+ : pc.bold("Git branch for Flux"),
149
+ placeholder: "main",
150
+ defaultValue: state.repoBranch,
151
+ });
152
+ if (p.isCancel(v))
153
+ return back();
154
+ return { ...state, repoBranch: v };
155
+ },
156
+ review: (state) => ["Branch", state.repoBranch],
157
+ },
158
+ // ── DNS & TLS ─────────────────────────────────────────────────────────
159
+ {
160
+ id: "manageDnsAndTls",
161
+ section: "DNS & TLS",
162
+ skip: (state) => saved(state, "manageDnsAndTls"),
163
+ run: async (state) => {
164
+ const v = await p.confirm({
165
+ message: pc.bold("Do you want to manage DNS and TLS (HTTPS) certificates automatically?") +
166
+ `\n${pc.dim("Existing DNS domain on Cloudflare required")}`,
167
+ initialValue: state.manageDnsAndTls,
168
+ });
169
+ if (p.isCancel(v))
170
+ return back();
171
+ return { ...state, manageDnsAndTls: v };
172
+ },
173
+ review: (state) => [
174
+ "Auto DNS & TLS",
175
+ state.manageDnsAndTls
176
+ ? "Yes — Cert Manager + External DNS (Cloudflare)"
177
+ : "No — manual DNS & certificates",
178
+ ],
179
+ },
180
+ // ── Components ───────────────────────────────────────────────────────
181
+ {
182
+ id: "selectedComponents",
183
+ section: "Components",
184
+ skip: (state) => saved(state, "selectedComponents"),
185
+ run: async (state) => {
186
+ const selected = await p.multiselect({
187
+ message: pc.bold("Optional components to install"),
188
+ options: OPTIONAL_COMPONENTS.map((c) => ({
189
+ value: c.id,
190
+ label: c.label,
191
+ hint: c.hint,
192
+ })),
193
+ initialValues: state.selectedComponents.filter((id) => OPTIONAL_COMPONENTS.some((c) => c.id === id)),
194
+ required: false,
195
+ });
196
+ if (p.isCancel(selected))
197
+ return back();
198
+ const dnsTlsIds = state.manageDnsAndTls ? DNS_TLS_COMPONENT_IDS : [];
199
+ return {
200
+ ...state,
201
+ selectedComponents: [
202
+ ...REQUIRED_COMPONENT_IDS,
203
+ ...dnsTlsIds,
204
+ ...selected,
205
+ ],
206
+ };
207
+ },
208
+ review: (state) => [
209
+ "Enabled",
210
+ state.selectedComponents.map(componentLabel).join(", "),
211
+ ],
212
+ },
213
+ // ── Cluster ──────────────────────────────────────────────────────────
214
+ {
215
+ id: "clusterName",
216
+ section: "Cluster",
217
+ skip: (state) => saved(state, "clusterName"),
218
+ run: async (state) => {
219
+ const v = await p.text({
220
+ message: pc.bold("Kubernetes cluster name"),
221
+ placeholder: "homelab",
222
+ defaultValue: state.clusterName,
223
+ });
224
+ if (p.isCancel(v))
225
+ return back();
226
+ return { ...state, clusterName: v };
227
+ },
228
+ review: (state) => ["Name", state.clusterName],
229
+ },
230
+ {
231
+ id: "clusterDomain",
232
+ section: "Cluster",
233
+ skip: (state) => saved(state, "clusterDomain"),
234
+ run: async (state) => {
235
+ const v = await p.text({
236
+ message: pc.bold("DNS domain for your cluster"),
237
+ placeholder: "homelab.click",
238
+ defaultValue: state.clusterDomain,
239
+ validate: (v) => {
240
+ if (v && !v.includes("."))
241
+ return "Must be a valid domain";
242
+ },
243
+ });
244
+ if (p.isCancel(v))
245
+ return back();
246
+ return { ...state, clusterDomain: v };
247
+ },
248
+ review: (state) => ["Domain", state.clusterDomain],
249
+ },
250
+ // ── Credentials & Secrets ────────────────────────────────────────────
251
+ {
252
+ id: "letsencryptEmail",
253
+ section: "Credentials & Secrets",
254
+ hidden: (state) => !dnsAndTlsEnabled(state),
255
+ skip: (state) => saved(state, "letsencryptEmail"),
256
+ run: async (state) => {
257
+ const v = await p.text({
258
+ message: pc.bold("Your email for Let's Encrypt certificate issuance"),
259
+ defaultValue: state.letsencryptEmail,
260
+ validate: (v) => {
261
+ if (!v || !v.includes("@"))
262
+ return "Must be a valid email";
263
+ },
264
+ });
265
+ if (p.isCancel(v))
266
+ return back();
267
+ return { ...state, letsencryptEmail: v };
268
+ },
269
+ review: (state) => ["Let's Encrypt email", state.letsencryptEmail],
270
+ },
271
+ {
272
+ id: "cloudflareApiToken",
273
+ section: "Credentials & Secrets",
274
+ hidden: (state) => !dnsAndTlsEnabled(state),
275
+ skip: (state) => !!state.cloudflareApiToken,
276
+ run: async (state) => {
277
+ const v = await p.password({
278
+ message: pc.bold("Cloudflare API Token (DNS zone edit access)"),
279
+ validate: (v) => {
280
+ if (!v)
281
+ return "Required";
282
+ },
283
+ });
284
+ if (p.isCancel(v))
285
+ return back();
286
+ return { ...state, cloudflareApiToken: v };
287
+ },
288
+ review: (state) => [
289
+ "Cloudflare token",
290
+ maskSecret(state.cloudflareApiToken),
291
+ ],
292
+ },
293
+ {
294
+ id: "openaiApiKey",
295
+ section: "Credentials & Secrets",
296
+ hidden: (state) => !openclawEnabled(state),
297
+ skip: (state) => !!state.openaiApiKey,
298
+ run: async (state) => {
299
+ const v = await p.password({
300
+ message: pc.bold("OpenAI API Key (for AI components)"),
301
+ validate: (v) => {
302
+ if (!v)
303
+ return "Required";
304
+ },
305
+ });
306
+ if (p.isCancel(v))
307
+ return back();
308
+ const openclawGatewayToken = state.openclawGatewayToken || exec("openssl rand -hex 32");
309
+ return {
310
+ ...state,
311
+ openaiApiKey: v,
312
+ openclawGatewayToken,
313
+ };
314
+ },
315
+ review: (state) => ["OpenAI key", maskSecret(state.openaiApiKey)],
316
+ },
317
+ // ── Network ──────────────────────────────────────────────────────────
318
+ {
319
+ id: "ingressAllowedIps",
320
+ section: "Network",
321
+ skip: (state) => saved(state, "ingressAllowedIps"),
322
+ run: async (state) => {
323
+ const v = await p.text({
324
+ message: pc.bold("IPs allowed to access your cluster (CIDR, comma-separated)"),
325
+ placeholder: "0.0.0.0/0",
326
+ defaultValue: state.ingressAllowedIps,
327
+ });
328
+ if (p.isCancel(v))
329
+ return back();
330
+ return { ...state, ingressAllowedIps: v };
331
+ },
332
+ review: (state) => ["Allowed IPs", state.ingressAllowedIps],
333
+ },
334
+ {
335
+ id: "clusterPublicIp",
336
+ section: "Network",
337
+ skip: (state) => saved(state, "clusterPublicIp"),
338
+ run: async (state) => {
339
+ const useLocal = !dnsAndTlsEnabled(state);
340
+ const fallback = useLocal ? "127.0.0.1" : detectedIp;
341
+ const defaultIp = state.clusterPublicIp || fallback;
342
+ const v = await p.text({
343
+ message: useLocal
344
+ ? pc.bold("Cluster IP") + pc.dim(" (local because DNS management is disabled. Rewrite if it necessary)")
345
+ : pc.bold("Public IP of your cluster"),
346
+ defaultValue: defaultIp,
347
+ placeholder: fallback || "x.x.x.x",
348
+ validate: (v) => {
349
+ if (!v && !defaultIp)
350
+ return "Required";
351
+ },
352
+ });
353
+ if (p.isCancel(v))
354
+ return back();
355
+ return { ...state, clusterPublicIp: v };
356
+ },
357
+ review: (state) => ["Public IP", state.clusterPublicIp],
358
+ },
359
+ ];
360
+ }
361
+ // ---------------------------------------------------------------------------
362
+ // Helpers
363
+ // ---------------------------------------------------------------------------
364
+ function resolveRepoRoot() {
365
+ const scriptDir = new URL(".", import.meta.url).pathname;
366
+ return resolve(scriptDir, "../../../");
367
+ }
368
+ // ---------------------------------------------------------------------------
369
+ // Repo creation phase (only for "new" mode)
370
+ // ---------------------------------------------------------------------------
371
+ async function createAndCloneRepo(wizard) {
372
+ log.step("Authenticating with GitLab");
373
+ await gitlab.authenticate(wizard.gitlabPat, SOURCE_GITLAB_HOST);
374
+ log.step(`Resolving namespace '${wizard.repoOwner}'`);
375
+ const namespaceId = await gitlab.resolveNamespaceId(wizard.repoOwner, SOURCE_GITLAB_HOST);
376
+ log.step(`Creating project ${wizard.repoOwner}/${wizard.repoName}`);
377
+ const existing = await gitlab.getProject(wizard.repoOwner, wizard.repoName, SOURCE_GITLAB_HOST);
378
+ let httpUrl;
379
+ let pathWithNs;
380
+ let repoExisted = false;
381
+ if (existing) {
382
+ log.warn(`Project '${wizard.repoOwner}/${wizard.repoName}' already exists (ID: ${existing.id})`);
383
+ const useExisting = await p.confirm({
384
+ message: "Use existing repository?",
385
+ initialValue: false,
386
+ });
387
+ if (p.isCancel(useExisting) || !useExisting) {
388
+ log.error("Aborting. Choose a different repo name or remove the existing project.");
389
+ return process.exit(1);
390
+ }
391
+ repoExisted = true;
392
+ httpUrl = existing.httpUrl;
393
+ pathWithNs = existing.pathWithNamespace;
394
+ log.success(`Using existing: ${pathWithNs}`);
395
+ }
396
+ else {
397
+ const created = await withSpinner("Creating GitLab project", () => gitlab.createProject(wizard.repoName, namespaceId, SOURCE_GITLAB_HOST));
398
+ httpUrl = created.httpUrl;
399
+ pathWithNs = created.pathWithNamespace;
400
+ log.success(`Created: ${pathWithNs}`);
401
+ }
402
+ const cloneDir = wizard.repoLocalPath || wizard.repoName;
403
+ if (existsSync(cloneDir)) {
404
+ log.warn(`Directory './${cloneDir}' already exists locally`);
405
+ const useDir = await p.confirm({
406
+ message: "Use existing directory?",
407
+ initialValue: false,
408
+ });
409
+ if (p.isCancel(useDir) || !useDir) {
410
+ log.error("Aborting. Remove or rename the existing directory and try again.");
411
+ return process.exit(1);
412
+ }
413
+ try {
414
+ exec(`git remote set-url origin "${httpUrl}"`, { cwd: cloneDir });
415
+ }
416
+ catch {
417
+ exec(`git remote add origin "${httpUrl}"`, { cwd: cloneDir });
418
+ }
419
+ }
420
+ else {
421
+ await withSpinner("Cloning template repository", () => execAsync(`git clone --quiet --branch "${wizard.repoBranch}" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
422
+ exec(`git remote set-url origin "${httpUrl}"`, { cwd: cloneDir });
423
+ }
424
+ const authRemote = `https://oauth2:${wizard.gitlabPat}@${SOURCE_GITLAB_HOST}/${pathWithNs}.git`;
425
+ await withSpinner(`Pushing to ${pathWithNs}`, () => {
426
+ const forceFlag = repoExisted ? " --force" : "";
427
+ return execAsync(`git push -u "${authRemote}" "${wizard.repoBranch}"${forceFlag} --quiet`, { cwd: cloneDir });
428
+ });
429
+ exec(`git remote set-url origin "${httpUrl}"`, { cwd: cloneDir });
430
+ summary("Repository Created", {
431
+ Repository: pathWithNs,
432
+ Directory: cloneDir,
433
+ });
434
+ process.chdir(cloneDir);
435
+ return process.cwd();
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // Openclaw device pairing (sub-command)
439
+ // ---------------------------------------------------------------------------
440
+ export async function openclawPair() {
441
+ header("OpenClaw Device Pairing");
442
+ p.log.info("1. Open Claude UI in your browser");
443
+ p.log.info("2. Enter your Gateway Token and click Connect");
444
+ const ready = await p.confirm({
445
+ message: "Have you submitted the pairing request?",
446
+ });
447
+ if (p.isCancel(ready) || !ready)
448
+ handleCancel();
449
+ log.step("Listing pending device requests");
450
+ try {
451
+ await execAsync("kubectl exec -n openclaw deployment/openclaw -c main -- node dist/index.js devices list");
452
+ }
453
+ catch {
454
+ log.error("Failed to list device requests");
455
+ return process.exit(1);
456
+ }
457
+ const requestId = await p.text({
458
+ message: "Enter REQUEST_ID to approve",
459
+ validate: (v) => {
460
+ if (!v)
461
+ return "REQUEST_ID must not be empty";
462
+ },
463
+ });
464
+ if (p.isCancel(requestId))
465
+ handleCancel();
466
+ log.step(`Approving device ${requestId}`);
467
+ await execAsync(`kubectl exec -n openclaw deployment/openclaw -c main -- node dist/index.js devices approve "${requestId}"`);
468
+ log.success("Device paired successfully");
469
+ }
470
+ // ---------------------------------------------------------------------------
471
+ // Main bootstrap flow
472
+ // ---------------------------------------------------------------------------
473
+ export async function bootstrap() {
474
+ // Ctrl+C exits immediately; Escape goes back one wizard step
475
+ p.settings?.aliases?.delete("\x03");
476
+ process.stdin.on("data", (data) => {
477
+ if (data[0] === 0x03) {
478
+ console.log();
479
+ p.cancel("Operation cancelled.");
480
+ process.exit(0);
481
+ }
482
+ });
483
+ console.log();
484
+ p.box(`💅 Secure, isolated and flexible GitOps infrastructure for modern requirements\n` +
485
+ `🤖 You can manage it yourself — or delegate to AI.\n` +
486
+ `🔐 Encrypted secrets, hardened containers, continuous delivery.`, pc.bold("Welcome to GitOps AI Bootstrapper"), {
487
+ contentAlign: "center",
488
+ titleAlign: "center",
489
+ rounded: true,
490
+ formatBorder: (text) => pc.cyan(text),
491
+ });
492
+ // ── Load saved state ─────────────────────────────────────────────────
493
+ const saved = loadInstallPlan();
494
+ if (saved) {
495
+ log.warn("Loading saved inputs from previous run");
496
+ }
497
+ const prev = (saved ?? {});
498
+ // ── Detect public IP (silent) ────────────────────────────────────────
499
+ let detectedIp = prev.clusterPublicIp ?? "";
500
+ if (!detectedIp) {
501
+ try {
502
+ detectedIp = await execAsync("curl -s --max-time 5 ifconfig.me");
503
+ }
504
+ catch {
505
+ detectedIp = "";
506
+ }
507
+ }
508
+ // ── Run interactive wizard ───────────────────────────────────────────
509
+ const savedDnsTls = prev.manageDnsAndTls !== undefined
510
+ ? prev.manageDnsAndTls === "true"
511
+ : true;
512
+ const savedComponents = prev.selectedComponents
513
+ ? prev.selectedComponents.split(",")
514
+ : [
515
+ ...REQUIRED_COMPONENT_IDS,
516
+ ...(savedDnsTls ? DNS_TLS_COMPONENT_IDS : []),
517
+ ...OPTIONAL_COMPONENTS.map((c) => c.id),
518
+ ];
519
+ const initialState = {
520
+ setupMode: prev.setupMode ?? "new",
521
+ manageDnsAndTls: savedDnsTls,
522
+ selectedComponents: savedComponents,
523
+ clusterName: prev.clusterName ?? "homelab",
524
+ clusterDomain: prev.clusterDomain ?? "homelab.click",
525
+ repoName: prev.repoName ?? "fluxcd_ai",
526
+ repoLocalPath: prev.repoLocalPath ?? "",
527
+ repoOwner: prev.repoOwner ?? "",
528
+ repoBranch: prev.repoBranch ?? "main",
529
+ letsencryptEmail: prev.letsencryptEmail ?? "",
530
+ gitlabPat: prev.gitlabPat ?? "",
531
+ cloudflareApiToken: prev.cloudflareApiToken ?? "",
532
+ openaiApiKey: prev.openaiApiKey ?? "",
533
+ openclawGatewayToken: prev.openclawGatewayToken ?? "",
534
+ ingressAllowedIps: prev.ingressAllowedIps ?? "0.0.0.0/0",
535
+ clusterPublicIp: prev.clusterPublicIp ?? detectedIp,
536
+ };
537
+ const wizard = await stepWizard(buildFields(detectedIp, !!saved), initialState);
538
+ // ── Save config ─────────────────────────────────────────────────────
539
+ saveInstallPlan({
540
+ setupMode: wizard.setupMode,
541
+ manageDnsAndTls: String(wizard.manageDnsAndTls),
542
+ clusterName: wizard.clusterName,
543
+ clusterDomain: wizard.clusterDomain,
544
+ clusterPublicIp: wizard.clusterPublicIp,
545
+ letsencryptEmail: wizard.letsencryptEmail,
546
+ ingressAllowedIps: wizard.ingressAllowedIps,
547
+ gitlabPat: wizard.gitlabPat,
548
+ repoName: wizard.repoName,
549
+ repoLocalPath: wizard.repoLocalPath,
550
+ repoOwner: wizard.repoOwner,
551
+ repoBranch: wizard.repoBranch,
552
+ cloudflareApiToken: wizard.cloudflareApiToken,
553
+ openaiApiKey: wizard.openaiApiKey ?? "",
554
+ openclawGatewayToken: wizard.openclawGatewayToken ?? "",
555
+ selectedComponents: wizard.selectedComponents.join(","),
556
+ });
557
+ log.success("Configuration saved");
558
+ // ── Warn about CLI tools that will be installed ─────────────────────
559
+ const toolDescriptions = [
560
+ ["git", "Version control (repo operations)"],
561
+ ["jq", "JSON processor (API responses)"],
562
+ ["glab", "GitLab CLI (repo & auth management)"],
563
+ ["kubectl", "Kubernetes CLI (cluster management)"],
564
+ ["helm", "Kubernetes package manager (chart installs)"],
565
+ ["k9s", "Terminal UI for Kubernetes (monitoring)"],
566
+ ["flux-operator", "FluxCD Operator CLI (GitOps reconciliation)"],
567
+ ["sops", "Mozilla SOPS (secret encryption)"],
568
+ ["age", "Age encryption (SOPS key backend)"],
569
+ ];
570
+ if (isMacOS() || isCI()) {
571
+ toolDescriptions.push(["k3d", "Lightweight K3s in Docker (local cluster)"]);
572
+ }
573
+ const toBeInstalled = toolDescriptions
574
+ .filter(([name]) => !commandExists(name));
575
+ const toolListFormatted = toolDescriptions
576
+ .map(([name, desc]) => {
577
+ const status = commandExists(name)
578
+ ? pc.green("installed")
579
+ : pc.yellow("will install");
580
+ return ` ${pc.bold(name.padEnd(16))} ${pc.dim(desc)} [${status}]`;
581
+ })
582
+ .join("\n");
583
+ const uninstallMac = toolDescriptions
584
+ .map(([name]) => name)
585
+ .join(" ");
586
+ p.note(`${pc.bold("The following CLI tools are required and will be installed if missing:")}\n\n` +
587
+ toolListFormatted +
588
+ "\n\n" +
589
+ pc.dim("─".repeat(60)) + "\n\n" +
590
+ pc.bold("Why are these needed?\n") +
591
+ pc.dim("These tools are used to create and manage your Kubernetes cluster,\n") +
592
+ pc.dim("deploy components via Helm/Flux, encrypt secrets, and interact with GitLab.\n\n") +
593
+ pc.bold("How to uninstall later:\n") +
594
+ (isMacOS()
595
+ ? ` ${pc.cyan(`brew uninstall ${uninstallMac}`)}\n`
596
+ : ` ${pc.cyan("sudo rm -f /usr/local/bin/{kubectl,helm,k9s,flux-operator,sops,age,age-keygen}")}\n` +
597
+ ` ${pc.cyan("sudo apt remove -y glab jq git")} ${pc.dim("(if installed via apt)")}\n`) +
598
+ pc.dim("\nAlready-installed tools will be skipped. No system tools will be modified."), "Required CLI Tools");
599
+ const confirmMsg = toBeInstalled.length > 0
600
+ ? `Install ${toBeInstalled.length} missing tool(s) and continue?`
601
+ : "All tools are already installed. Continue?";
602
+ const proceed = await p.confirm({
603
+ message: pc.bold(confirmMsg),
604
+ initialValue: true,
605
+ });
606
+ if (p.isCancel(proceed) || !proceed) {
607
+ log.error("Aborted.");
608
+ return process.exit(1);
609
+ }
610
+ // ── Install all CLI tools upfront ───────────────────────────────────
611
+ if (toBeInstalled.length > 0) {
612
+ log.step("Installing CLI tools");
613
+ const allToolNames = toolDescriptions.map(([name]) => name);
614
+ await ensureAll(allToolNames);
615
+ }
616
+ // ── Repo creation phase (new mode only) ─────────────────────────────
617
+ let repoRoot;
618
+ if (isNewRepo(wizard)) {
619
+ try {
620
+ repoRoot = await createAndCloneRepo(wizard);
621
+ }
622
+ catch (err) {
623
+ log.error(`Repository setup failed\n${formatError(err)}`);
624
+ return process.exit(1);
625
+ }
626
+ }
627
+ else {
628
+ repoRoot = resolveRepoRoot();
629
+ }
630
+ // ── Build final config ───────────────────────────────────────────────
631
+ const selectedComponents = wizard.selectedComponents;
632
+ const isOpenclawEnabled = openclawEnabled(wizard);
633
+ const fullConfig = {
634
+ clusterName: wizard.clusterName,
635
+ clusterDomain: wizard.clusterDomain,
636
+ clusterPublicIp: wizard.clusterPublicIp,
637
+ letsencryptEmail: wizard.letsencryptEmail,
638
+ ingressAllowedIps: wizard.ingressAllowedIps,
639
+ gitlabPat: wizard.gitlabPat,
640
+ repoName: wizard.repoName,
641
+ repoOwner: wizard.repoOwner,
642
+ repoBranch: wizard.repoBranch,
643
+ cloudflareApiToken: wizard.cloudflareApiToken,
644
+ openaiApiKey: isOpenclawEnabled ? wizard.openaiApiKey : undefined,
645
+ openclawGatewayToken: isOpenclawEnabled
646
+ ? wizard.openclawGatewayToken
647
+ : undefined,
648
+ selectedComponents,
649
+ };
650
+ // ── Check macOS prerequisites ────────────────────────────────────────
651
+ if (isMacOS()) {
652
+ if (!commandExists("brew")) {
653
+ log.error("Homebrew is required on macOS. Install from https://brew.sh");
654
+ return process.exit(1);
655
+ }
656
+ if (!commandExists("docker")) {
657
+ log.error("Docker is required on macOS (Docker Desktop, OrbStack, or Colima).");
658
+ return process.exit(1);
659
+ }
660
+ }
661
+ // ── Run bootstrap (cluster + flux + template + sops + git push) ─────
662
+ try {
663
+ await runBootstrap(fullConfig, repoRoot);
664
+ }
665
+ catch (err) {
666
+ log.error(`Bootstrap failed\n${formatError(err)}`);
667
+ return process.exit(1);
668
+ }
669
+ // ── /etc/hosts suggestion (no DNS management) ───────────────────────
670
+ if (!wizard.manageDnsAndTls) {
671
+ const hostsEntries = selectedComponents
672
+ .map((id) => COMPONENTS.find((c) => c.id === id))
673
+ .filter((c) => !!c?.subdomain)
674
+ .map((c) => `${fullConfig.clusterPublicIp} ${c.subdomain}.${fullConfig.clusterDomain}`);
675
+ if (hostsEntries.length > 0) {
676
+ const hostsBlock = hostsEntries.join("\n");
677
+ p.note(`${pc.dim("Since automatic DNS is disabled, add these to")} ${pc.bold("/etc/hosts")}${pc.dim(":")}\n\n` +
678
+ hostsEntries.map((e) => pc.cyan(e)).join("\n"), "Local DNS");
679
+ const addHosts = await p.confirm({
680
+ message: pc.bold("Append these entries to /etc/hosts now?"),
681
+ initialValue: false,
682
+ });
683
+ if (!p.isCancel(addHosts) && addHosts) {
684
+ try {
685
+ await execAsync(`echo '\n# GitOps AI Cluster — ${fullConfig.clusterName}\n${hostsBlock}' | sudo tee -a /etc/hosts > /dev/null`);
686
+ log.success("Entries added to /etc/hosts");
687
+ }
688
+ catch (err) {
689
+ log.warn("Could not update /etc/hosts — add the entries manually");
690
+ log.detail(formatError(err));
691
+ }
692
+ }
693
+ }
694
+ }
695
+ // ── Status & summary ─────────────────────────────────────────────────
696
+ const status = flux.getStatus();
697
+ if (status)
698
+ p.log.message(status);
699
+ const summaryEntries = {
700
+ "Cluster": fullConfig.clusterName,
701
+ "Domain": fullConfig.clusterDomain,
702
+ "Public IP": fullConfig.clusterPublicIp,
703
+ "Components": selectedComponents.length.toString(),
704
+ };
705
+ if (isOpenclawEnabled && fullConfig.openclawGatewayToken) {
706
+ summaryEntries["OpenClaw Gateway Token"] = fullConfig.openclawGatewayToken;
707
+ }
708
+ summary("Bootstrap Complete", summaryEntries);
709
+ const finalSteps = [
710
+ `All HelmReleases may take ${pc.yellow("~5 minutes")} to become ready.`,
711
+ `Check status: ${pc.cyan("kubectl get helmreleases -A")} or ${pc.cyan("k9s -A")}`,
712
+ ];
713
+ if (isOpenclawEnabled) {
714
+ finalSteps.push(`Open OpenClaw at ${pc.cyan(`https://openclaw.${fullConfig.clusterDomain}`)}`, `Pair a device: ${pc.cyan("npx fluxcd-ai-bootstraper openclaw-pair")}`);
715
+ }
716
+ finalSteps.push(`Your infrastructure is managed via ${pc.bold("GitOps")} — push to '${fullConfig.repoBranch}' to deploy changes.`, `${pc.red("Backup your SOPS age key!")} If lost, you cannot decrypt your secrets.`);
717
+ nextSteps(finalSteps);
718
+ clearInstallPlan();
719
+ finish("Bootstrap complete");
720
+ }
721
+ //# sourceMappingURL=bootstrap.js.map