gitops-ai 1.0.0 → 1.2.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -41
  3. package/dist/commands/bootstrap.js +641 -117
  4. package/dist/commands/bootstrap.js.map +1 -1
  5. package/dist/commands/template-sync-wizard.d.ts +1 -0
  6. package/dist/commands/template-sync-wizard.js +169 -0
  7. package/dist/commands/template-sync-wizard.js.map +1 -0
  8. package/dist/commands/template-sync.d.ts +8 -0
  9. package/dist/commands/template-sync.js +41 -0
  10. package/dist/commands/template-sync.js.map +1 -0
  11. package/dist/core/bootstrap-runner.js +28 -11
  12. package/dist/core/bootstrap-runner.js.map +1 -1
  13. package/dist/core/cloudflare-oauth.d.ts +1 -0
  14. package/dist/core/cloudflare-oauth.js +311 -0
  15. package/dist/core/cloudflare-oauth.js.map +1 -0
  16. package/dist/core/dependencies.js +0 -12
  17. package/dist/core/dependencies.js.map +1 -1
  18. package/dist/core/encryption.js +1 -1
  19. package/dist/core/encryption.js.map +1 -1
  20. package/dist/core/flux.d.ts +1 -1
  21. package/dist/core/flux.js +57 -8
  22. package/dist/core/flux.js.map +1 -1
  23. package/dist/core/git-provider.d.ts +38 -0
  24. package/dist/core/git-provider.js +30 -0
  25. package/dist/core/git-provider.js.map +1 -0
  26. package/dist/core/github-oauth.d.ts +1 -0
  27. package/dist/core/github-oauth.js +110 -0
  28. package/dist/core/github-oauth.js.map +1 -0
  29. package/dist/core/github.d.ts +12 -0
  30. package/dist/core/github.js +188 -0
  31. package/dist/core/github.js.map +1 -0
  32. package/dist/core/gitlab-oauth.d.ts +1 -0
  33. package/dist/core/gitlab-oauth.js +194 -0
  34. package/dist/core/gitlab-oauth.js.map +1 -0
  35. package/dist/core/gitlab.d.ts +4 -9
  36. package/dist/core/gitlab.js +127 -56
  37. package/dist/core/gitlab.js.map +1 -1
  38. package/dist/core/kubernetes.d.ts +9 -0
  39. package/dist/core/kubernetes.js +51 -1
  40. package/dist/core/kubernetes.js.map +1 -1
  41. package/dist/core/template-sync.d.ts +46 -0
  42. package/dist/core/template-sync.js +249 -0
  43. package/dist/core/template-sync.js.map +1 -0
  44. package/dist/index.js +5 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/schemas.d.ts +17 -4
  47. package/dist/schemas.js +17 -3
  48. package/dist/schemas.js.map +1 -1
  49. package/package.json +32 -2
@@ -1,17 +1,35 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import { networkInterfaces } from "node:os";
2
4
  import { resolve } from "node:path";
3
5
  import * as p from "@clack/prompts";
4
6
  import pc from "picocolors";
5
7
  import { header, log, summary, nextSteps, finish, handleCancel, withSpinner, formatError, } from "../utils/log.js";
6
8
  import { saveInstallPlan, loadInstallPlan, clearInstallPlan } from "../utils/config.js";
7
- import { execAsync, exec, commandExists } from "../utils/shell.js";
9
+ import { execAsync, exec, execSafe, commandExists } from "../utils/shell.js";
8
10
  import { isMacOS, isCI } from "../utils/platform.js";
9
11
  import { ensureAll } from "../core/dependencies.js";
10
12
  import { runBootstrap } from "../core/bootstrap-runner.js";
13
+ import * as k8s from "../core/kubernetes.js";
11
14
  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";
15
+ import { getProvider, } from "../core/git-provider.js";
16
+ import { COMPONENTS, REQUIRED_COMPONENT_IDS, DNS_TLS_COMPONENT_IDS, MONITORING_COMPONENT_IDS, OPTIONAL_COMPONENTS, SOURCE_GITLAB_HOST, SOURCE_PROJECT_PATH, } from "../schemas.js";
17
+ import { fetchTemplateTags, readPackageVersion } from "../core/template-sync.js";
14
18
  import { stepWizard, back, maskSecret, } from "../utils/wizard.js";
19
+ import { loginAndCreateCloudflareToken } from "../core/cloudflare-oauth.js";
20
+ // ---------------------------------------------------------------------------
21
+ // Browser opener
22
+ // ---------------------------------------------------------------------------
23
+ function openUrl(url) {
24
+ const cmd = isMacOS() ? "open" : "xdg-open";
25
+ try {
26
+ execSync(`${cmd} '${url}'`, { stdio: "ignore" });
27
+ }
28
+ catch {
29
+ /* user will see the manual URL in the terminal */
30
+ }
31
+ }
32
+ const OPENAI_API_KEYS_URL = "https://platform.openai.com/api-keys";
15
33
  function isNewRepo(state) {
16
34
  return state.setupMode === "new";
17
35
  }
@@ -24,6 +42,25 @@ function openclawEnabled(state) {
24
42
  function componentLabel(id) {
25
43
  return COMPONENTS.find((c) => c.id === id)?.label ?? id;
26
44
  }
45
+ // fetchTemplateTags is imported from core/template-sync.ts
46
+ function providerLabel(type) {
47
+ return type === "gitlab" ? "GitLab" : "GitHub";
48
+ }
49
+ async function enrichWithUser(state, token, provider) {
50
+ try {
51
+ const user = await provider.fetchCurrentUser(token, provider.defaultHost);
52
+ log.success(`Logged in as ${user.username}`);
53
+ return {
54
+ ...state,
55
+ gitToken: token,
56
+ repoOwner: state.repoOwner || user.username,
57
+ };
58
+ }
59
+ catch (err) {
60
+ log.warn(`Could not detect ${providerLabel(state.gitProvider)} user: ${err.message}`);
61
+ return { ...state, gitToken: token };
62
+ }
63
+ }
27
64
  // ---------------------------------------------------------------------------
28
65
  // Wizard field definitions (Esc / Ctrl+C = go back one field)
29
66
  // ---------------------------------------------------------------------------
@@ -63,32 +100,140 @@ function buildFields(detectedIp, hasSavedPlan) {
63
100
  : "Use existing repo",
64
101
  ],
65
102
  },
66
- // ── GitLab Repository ───────────────────────────────────────────────
103
+ // ── Git Provider ────────────────────────────────────────────────────
67
104
  {
68
- id: "gitlabPat",
69
- section: "GitLab Repository",
70
- skip: (state) => !!state.gitlabPat,
105
+ id: "gitProvider",
106
+ section: "Git Provider",
107
+ skip: (state) => saved(state, "gitProvider"),
71
108
  run: async (state) => {
109
+ const v = await p.select({
110
+ message: pc.bold("Which Git provider do you want to use?"),
111
+ options: [
112
+ {
113
+ value: "github",
114
+ label: "GitHub",
115
+ hint: "github.com or GitHub Enterprise",
116
+ },
117
+ {
118
+ value: "gitlab",
119
+ label: "GitLab",
120
+ hint: "gitlab.com or self-hosted",
121
+ },
122
+ ],
123
+ initialValue: state.gitProvider,
124
+ });
125
+ if (p.isCancel(v))
126
+ return back();
127
+ return { ...state, gitProvider: v };
128
+ },
129
+ review: (state) => ["Provider", providerLabel(state.gitProvider)],
130
+ },
131
+ // ── Git Repository ──────────────────────────────────────────────────
132
+ {
133
+ id: "gitToken",
134
+ section: "Git Repository",
135
+ skip: (state) => !!state.gitToken,
136
+ run: async (state) => {
137
+ const provider = await getProvider(state.gitProvider);
138
+ if (isCI()) {
139
+ const v = await p.password({
140
+ message: pc.bold(provider.tokenLabel),
141
+ validate: (v) => { if (!v)
142
+ return "Required"; },
143
+ });
144
+ if (p.isCancel(v))
145
+ return back();
146
+ return { ...state, gitToken: v };
147
+ }
148
+ const method = await p.select({
149
+ message: pc.bold(`How would you like to authenticate with ${providerLabel(state.gitProvider)}?`),
150
+ options: [
151
+ {
152
+ value: "browser",
153
+ label: "Login with browser",
154
+ hint: `opens ${providerLabel(state.gitProvider)} in your browser — recommended`,
155
+ },
156
+ {
157
+ value: "pat",
158
+ label: "Paste a Personal Access Token",
159
+ hint: "manual token entry",
160
+ },
161
+ ],
162
+ });
163
+ if (p.isCancel(method))
164
+ return back();
165
+ if (method === "browser") {
166
+ try {
167
+ const token = await provider.loginWithBrowser(provider.defaultHost);
168
+ log.success("Authenticated via browser");
169
+ return await enrichWithUser(state, token, provider);
170
+ }
171
+ catch (err) {
172
+ log.warn(`Browser login failed: ${err.message}`);
173
+ log.warn("Falling back to manual token entry");
174
+ }
175
+ }
72
176
  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
- },
177
+ message: pc.bold(provider.tokenLabel),
178
+ validate: (v) => { if (!v)
179
+ return "Required"; },
78
180
  });
79
181
  if (p.isCancel(v))
80
182
  return back();
81
- return { ...state, gitlabPat: v };
183
+ return await enrichWithUser(state, v, provider);
82
184
  },
83
- review: (state) => ["PAT", maskSecret(state.gitlabPat)],
185
+ review: (state) => ["Token", maskSecret(state.gitToken)],
84
186
  },
85
187
  {
86
188
  id: "repoOwner",
87
- section: "GitLab Repository",
88
- skip: (state) => saved(state, "repoOwner"),
189
+ section: "Git Repository",
190
+ skip: (state) => !!state.repoOwner,
89
191
  run: async (state) => {
192
+ const provider = await getProvider(state.gitProvider);
193
+ const label = providerLabel(state.gitProvider);
194
+ const orgLabel = state.gitProvider === "gitlab" ? "group" : "org";
195
+ try {
196
+ const user = await provider.fetchCurrentUser(state.gitToken, provider.defaultHost);
197
+ let orgs = [];
198
+ try {
199
+ orgs = await provider.fetchOrganizations(state.gitToken, provider.defaultHost);
200
+ }
201
+ catch {
202
+ /* no orgs or insufficient permissions — continue with personal only */
203
+ }
204
+ const options = [
205
+ {
206
+ value: user.username,
207
+ label: user.username,
208
+ hint: `personal · ${user.name}`,
209
+ },
210
+ ...orgs.map((g) => ({
211
+ value: g.fullPath,
212
+ label: g.fullPath,
213
+ hint: orgLabel,
214
+ })),
215
+ {
216
+ value: "__manual__",
217
+ label: "Enter manually…",
218
+ hint: "type a namespace",
219
+ },
220
+ ];
221
+ const v = await p.select({
222
+ message: pc.bold(`${label} namespace for the repository`),
223
+ options,
224
+ initialValue: state.repoOwner || user.username,
225
+ });
226
+ if (p.isCancel(v))
227
+ return back();
228
+ if (v !== "__manual__") {
229
+ return { ...state, repoOwner: v };
230
+ }
231
+ }
232
+ catch {
233
+ /* API unavailable — fall through to manual input */
234
+ }
90
235
  const v = await p.text({
91
- message: pc.bold("GitLab repo owner / namespace (without @)"),
236
+ message: pc.bold(`${label} repo owner / namespace`),
92
237
  placeholder: "my-username-or-group",
93
238
  initialValue: state.repoOwner || undefined,
94
239
  validate: (v) => {
@@ -104,13 +249,54 @@ function buildFields(detectedIp, hasSavedPlan) {
104
249
  },
105
250
  {
106
251
  id: "repoName",
107
- section: "GitLab Repository",
252
+ section: "Git Repository",
108
253
  skip: (state) => saved(state, "repoName"),
109
254
  run: async (state) => {
255
+ if (isNewRepo(state)) {
256
+ const v = await p.text({
257
+ message: pc.bold("New repository name"),
258
+ placeholder: "fluxcd_ai",
259
+ defaultValue: state.repoName,
260
+ });
261
+ if (p.isCancel(v))
262
+ return back();
263
+ return { ...state, repoName: v };
264
+ }
265
+ const provider = await getProvider(state.gitProvider);
266
+ try {
267
+ const projects = await provider.fetchNamespaceProjects(state.gitToken, provider.defaultHost, state.repoOwner);
268
+ if (projects.length > 0) {
269
+ const options = [
270
+ ...projects.map((proj) => ({
271
+ value: proj.name,
272
+ label: proj.name,
273
+ hint: proj.description
274
+ ? proj.description.slice(0, 60)
275
+ : undefined,
276
+ })),
277
+ {
278
+ value: "__manual__",
279
+ label: "Enter manually…",
280
+ hint: "type a repo name",
281
+ },
282
+ ];
283
+ const v = await p.select({
284
+ message: pc.bold("Select repository"),
285
+ options,
286
+ initialValue: state.repoName || undefined,
287
+ });
288
+ if (p.isCancel(v))
289
+ return back();
290
+ if (v !== "__manual__") {
291
+ return { ...state, repoName: v };
292
+ }
293
+ }
294
+ }
295
+ catch {
296
+ /* fall through to manual input */
297
+ }
110
298
  const v = await p.text({
111
- message: isNewRepo(state)
112
- ? pc.bold("New repository name")
113
- : pc.bold("Flux GitLab repo name"),
299
+ message: pc.bold("Flux repo name"),
114
300
  placeholder: "fluxcd_ai",
115
301
  defaultValue: state.repoName,
116
302
  });
@@ -122,30 +308,48 @@ function buildFields(detectedIp, hasSavedPlan) {
122
308
  },
123
309
  {
124
310
  id: "repoLocalPath",
125
- section: "GitLab Repository",
126
- hidden: (state) => !isNewRepo(state),
311
+ section: "Git Repository",
127
312
  skip: (state) => saved(state, "repoLocalPath"),
128
313
  run: async (state) => {
314
+ if (isNewRepo(state)) {
315
+ const v = await p.text({
316
+ message: pc.bold("Local directory to clone into"),
317
+ placeholder: `./${state.repoName} (relative to current directory)`,
318
+ defaultValue: state.repoLocalPath || state.repoName,
319
+ });
320
+ if (p.isCancel(v))
321
+ return back();
322
+ return { ...state, repoLocalPath: v };
323
+ }
129
324
  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,
325
+ message: pc.bold("Path to your local repo checkout"),
326
+ placeholder: `./${state.repoName} (relative or absolute path)`,
327
+ defaultValue: state.repoLocalPath || ".",
328
+ validate: (val) => {
329
+ const target = resolve(val || ".");
330
+ if (!existsSync(target))
331
+ return "Directory does not exist";
332
+ const gitCheck = execSafe("git rev-parse --is-inside-work-tree", { cwd: target });
333
+ if (gitCheck.exitCode !== 0)
334
+ return "Not a git repository — run git init or clone first";
335
+ return undefined;
336
+ },
133
337
  });
134
338
  if (p.isCancel(v))
135
339
  return back();
136
340
  return { ...state, repoLocalPath: v };
137
341
  },
138
- review: (state) => ["Local path", `./${state.repoLocalPath}`],
342
+ review: (state) => isNewRepo(state)
343
+ ? ["Local path", `./${state.repoLocalPath}`]
344
+ : ["Local repo path", resolve(state.repoLocalPath || ".")],
139
345
  },
140
346
  {
141
347
  id: "repoBranch",
142
- section: "GitLab Repository",
348
+ section: "Git Repository",
143
349
  skip: (state) => saved(state, "repoBranch"),
144
350
  run: async (state) => {
145
351
  const v = await p.text({
146
- message: isNewRepo(state)
147
- ? pc.bold("Template branch name to clone")
148
- : pc.bold("Git branch for Flux"),
352
+ message: pc.bold("Git branch for Flux"),
149
353
  placeholder: "main",
150
354
  defaultValue: state.repoBranch,
151
355
  });
@@ -155,6 +359,48 @@ function buildFields(detectedIp, hasSavedPlan) {
155
359
  },
156
360
  review: (state) => ["Branch", state.repoBranch],
157
361
  },
362
+ {
363
+ id: "templateTag",
364
+ section: "Git Repository",
365
+ hidden: (state) => !isNewRepo(state),
366
+ skip: (state) => saved(state, "templateTag"),
367
+ run: async (state) => {
368
+ const tags = await fetchTemplateTags();
369
+ if (tags.length > 0) {
370
+ const options = [
371
+ ...tags.map((tag, i) => ({
372
+ value: tag,
373
+ label: tag,
374
+ hint: i === 0 ? "latest" : undefined,
375
+ })),
376
+ {
377
+ value: "__manual__",
378
+ label: "Enter manually…",
379
+ hint: "type a tag or branch name",
380
+ },
381
+ ];
382
+ const v = await p.select({
383
+ message: pc.bold("Template version (tag) to clone"),
384
+ options,
385
+ initialValue: state.templateTag || tags[0],
386
+ });
387
+ if (p.isCancel(v))
388
+ return back();
389
+ if (v !== "__manual__") {
390
+ return { ...state, templateTag: v };
391
+ }
392
+ }
393
+ const v = await p.text({
394
+ message: pc.bold("Template tag or branch to clone"),
395
+ placeholder: "main",
396
+ defaultValue: state.templateTag || "main",
397
+ });
398
+ if (p.isCancel(v))
399
+ return back();
400
+ return { ...state, templateTag: v };
401
+ },
402
+ review: (state) => ["Template tag", state.templateTag],
403
+ },
158
404
  // ── DNS & TLS ─────────────────────────────────────────────────────────
159
405
  {
160
406
  id: "manageDnsAndTls",
@@ -183,25 +429,44 @@ function buildFields(detectedIp, hasSavedPlan) {
183
429
  section: "Components",
184
430
  skip: (state) => saved(state, "selectedComponents"),
185
431
  run: async (state) => {
432
+ const MONITORING_GROUP_ID = "__monitoring__";
433
+ const monitoringOption = {
434
+ value: MONITORING_GROUP_ID,
435
+ label: "Monitoring Stack",
436
+ hint: "Victoria Metrics + Grafana Operator (dashboards, alerting, metrics)",
437
+ };
438
+ const hasMonitoring = MONITORING_COMPONENT_IDS.every((id) => state.selectedComponents.includes(id));
439
+ const monitoringExplicitlyRemoved = !hasMonitoring
440
+ && state.selectedComponents.some((id) => !REQUIRED_COMPONENT_IDS.includes(id));
186
441
  const selected = await p.multiselect({
187
442
  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)),
443
+ options: [
444
+ monitoringOption,
445
+ ...OPTIONAL_COMPONENTS.map((c) => ({
446
+ value: c.id,
447
+ label: c.label,
448
+ hint: c.hint,
449
+ })),
450
+ ],
451
+ initialValues: [
452
+ ...((hasMonitoring || !monitoringExplicitlyRemoved) ? [MONITORING_GROUP_ID] : []),
453
+ ...state.selectedComponents.filter((id) => OPTIONAL_COMPONENTS.some((c) => c.id === id)),
454
+ ],
194
455
  required: false,
195
456
  });
196
457
  if (p.isCancel(selected))
197
458
  return back();
459
+ const picks = selected;
460
+ const monitoringIds = picks.includes(MONITORING_GROUP_ID) ? MONITORING_COMPONENT_IDS : [];
461
+ const otherIds = picks.filter((id) => id !== MONITORING_GROUP_ID);
198
462
  const dnsTlsIds = state.manageDnsAndTls ? DNS_TLS_COMPONENT_IDS : [];
199
463
  return {
200
464
  ...state,
201
465
  selectedComponents: [
202
466
  ...REQUIRED_COMPONENT_IDS,
203
467
  ...dnsTlsIds,
204
- ...selected,
468
+ ...monitoringIds,
469
+ ...otherIds,
205
470
  ],
206
471
  };
207
472
  },
@@ -274,12 +539,47 @@ function buildFields(detectedIp, hasSavedPlan) {
274
539
  hidden: (state) => !dnsAndTlsEnabled(state),
275
540
  skip: (state) => !!state.cloudflareApiToken,
276
541
  run: async (state) => {
542
+ if (isCI()) {
543
+ const v = await p.password({
544
+ message: pc.bold("Cloudflare API Token (DNS zone edit access)"),
545
+ validate: (v) => { if (!v)
546
+ return "Required"; },
547
+ });
548
+ if (p.isCancel(v))
549
+ return back();
550
+ return { ...state, cloudflareApiToken: v };
551
+ }
552
+ const method = await p.select({
553
+ message: pc.bold("How would you like to authenticate with Cloudflare?"),
554
+ options: [
555
+ {
556
+ value: "browser",
557
+ label: "Login with browser",
558
+ hint: "OAuth login → auto-creates a scoped DNS token — recommended",
559
+ },
560
+ {
561
+ value: "pat",
562
+ label: "Paste an API Token",
563
+ hint: "manual token from dash.cloudflare.com",
564
+ },
565
+ ],
566
+ });
567
+ if (p.isCancel(method))
568
+ return back();
569
+ if (method === "browser") {
570
+ try {
571
+ const token = await loginAndCreateCloudflareToken(state.clusterDomain);
572
+ return { ...state, cloudflareApiToken: token };
573
+ }
574
+ catch (err) {
575
+ log.warn(`Browser login failed: ${err.message}`);
576
+ log.warn("Falling back to manual token entry");
577
+ }
578
+ }
277
579
  const v = await p.password({
278
580
  message: pc.bold("Cloudflare API Token (DNS zone edit access)"),
279
- validate: (v) => {
280
- if (!v)
281
- return "Required";
282
- },
581
+ validate: (v) => { if (!v)
582
+ return "Required"; },
283
583
  });
284
584
  if (p.isCancel(v))
285
585
  return back();
@@ -296,12 +596,51 @@ function buildFields(detectedIp, hasSavedPlan) {
296
596
  hidden: (state) => !openclawEnabled(state),
297
597
  skip: (state) => !!state.openaiApiKey,
298
598
  run: async (state) => {
599
+ if (isCI()) {
600
+ const v = await p.password({
601
+ message: pc.bold("OpenAI API Key"),
602
+ validate: (v) => { if (!v)
603
+ return "Required"; },
604
+ });
605
+ if (p.isCancel(v))
606
+ return back();
607
+ const openclawGatewayToken = state.openclawGatewayToken || exec("openssl rand -hex 32");
608
+ return { ...state, openaiApiKey: v, openclawGatewayToken };
609
+ }
610
+ const method = await p.select({
611
+ message: pc.bold("How would you like to provide your OpenAI API key?"),
612
+ options: [
613
+ {
614
+ value: "browser",
615
+ label: "Open dashboard in browser",
616
+ hint: "opens platform.openai.com to create a key — recommended",
617
+ },
618
+ {
619
+ value: "paste",
620
+ label: "Paste an API Key",
621
+ hint: "manual key entry",
622
+ },
623
+ ],
624
+ });
625
+ if (p.isCancel(method))
626
+ return back();
627
+ if (method === "browser") {
628
+ p.note(`${pc.bold("Create an API key with these steps:")}\n\n` +
629
+ ` 1. Log in to ${pc.cyan("platform.openai.com")}\n` +
630
+ ` 2. Click ${pc.cyan("+ Create new secret key")}\n` +
631
+ ` 3. Name it (e.g. ${pc.cyan("gitops-ai")})\n` +
632
+ ` 4. Copy the key value\n\n` +
633
+ pc.dim("The key starts with sk-… and is only shown once."), "OpenAI API Key");
634
+ await p.text({
635
+ message: pc.dim("Press ") + pc.bold(pc.yellow("Enter")) + pc.dim(" to open browser…"),
636
+ defaultValue: "",
637
+ });
638
+ openUrl(OPENAI_API_KEYS_URL);
639
+ }
299
640
  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
- },
641
+ message: pc.bold("Paste the OpenAI API key"),
642
+ validate: (v) => { if (!v)
643
+ return "Required"; },
305
644
  });
306
645
  if (p.isCancel(v))
307
646
  return back();
@@ -316,65 +655,162 @@ function buildFields(detectedIp, hasSavedPlan) {
316
655
  },
317
656
  // ── Network ──────────────────────────────────────────────────────────
318
657
  {
319
- id: "ingressAllowedIps",
658
+ id: "networkAccessMode",
320
659
  section: "Network",
321
- skip: (state) => saved(state, "ingressAllowedIps"),
660
+ skip: (state) => saved(state, "ingressAllowedIps") && saved(state, "clusterPublicIp"),
322
661
  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,
662
+ const mode = await p.select({
663
+ message: pc.bold("How will you access the cluster?"),
664
+ options: [
665
+ {
666
+ value: "public",
667
+ label: "Public",
668
+ hint: "auto-detects your public IP",
669
+ },
670
+ {
671
+ value: "local",
672
+ label: "Local only (localhost / LAN)",
673
+ hint: "uses 127.0.0.1 or a private IP",
674
+ },
675
+ ],
327
676
  });
328
- if (p.isCancel(v))
677
+ if (p.isCancel(mode))
329
678
  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
- },
679
+ const m = mode;
680
+ async function resolvePublicIp() {
681
+ if (detectedIp)
682
+ return detectedIp;
683
+ const services = ["ifconfig.me", "api.ipify.org", "icanhazip.com"];
684
+ await withSpinner("Detecting public IP", async () => {
685
+ for (const svc of services) {
686
+ try {
687
+ const ip = (await execAsync(`curl -s --max-time 4 ${svc}`)).trim();
688
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
689
+ detectedIp = ip;
690
+ return;
691
+ }
692
+ }
693
+ catch { /* try next */ }
694
+ }
695
+ });
696
+ return detectedIp;
697
+ }
698
+ if (m === "public") {
699
+ const publicIp = await resolvePublicIp();
700
+ const confirmIp = await p.text({
701
+ message: pc.bold("Public IP of your cluster"),
702
+ ...(publicIp
703
+ ? { initialValue: publicIp }
704
+ : { placeholder: "x.x.x.x" }),
705
+ validate: (v) => { if (!v)
706
+ return "Required"; },
707
+ });
708
+ if (p.isCancel(confirmIp))
709
+ return back();
710
+ const restriction = await p.select({
711
+ message: pc.bold("Restrict ingress access?"),
712
+ options: [
713
+ {
714
+ value: "open",
715
+ label: "Open to everyone (0.0.0.0/0)",
716
+ hint: "any IP can reach the cluster",
717
+ },
718
+ {
719
+ value: "restrict",
720
+ label: "Restrict to specific IPs",
721
+ hint: "only listed CIDRs can reach the cluster",
722
+ },
723
+ ],
724
+ });
725
+ if (p.isCancel(restriction))
726
+ return back();
727
+ if (restriction === "open") {
728
+ return { ...state, clusterPublicIp: confirmIp, ingressAllowedIps: "0.0.0.0/0" };
729
+ }
730
+ const allowedCidrs = await p.text({
731
+ message: pc.bold("Allowed CIDRs (comma-separated)"),
732
+ placeholder: "203.0.113.0/24,198.51.100.5/32",
733
+ validate: (v) => { if (!v)
734
+ return "At least one CIDR is required"; },
735
+ });
736
+ if (p.isCancel(allowedCidrs))
737
+ return back();
738
+ return { ...state, clusterPublicIp: confirmIp, ingressAllowedIps: allowedCidrs };
739
+ }
740
+ // local — detect LAN IPs from network interfaces
741
+ const lanIps = [];
742
+ const ifaces = networkInterfaces();
743
+ for (const [name, addrs] of Object.entries(ifaces)) {
744
+ for (const addr of addrs ?? []) {
745
+ if (addr.family === "IPv4" && !addr.internal) {
746
+ lanIps.push({ ip: addr.address, iface: name });
747
+ }
748
+ }
749
+ }
750
+ let localIp;
751
+ if (lanIps.length > 0) {
752
+ localIp = await p.select({
753
+ message: pc.bold("Cluster IP"),
754
+ options: [
755
+ ...lanIps.map((l) => ({
756
+ value: l.ip,
757
+ label: l.ip,
758
+ hint: l.iface,
759
+ })),
760
+ { value: "127.0.0.1", label: "127.0.0.1", hint: "localhost" },
761
+ { value: "__custom__", label: "Enter manually" },
762
+ ],
763
+ });
764
+ if (p.isCancel(localIp))
765
+ return back();
766
+ if (localIp === "__custom__") {
767
+ localIp = await p.text({
768
+ message: pc.bold("Cluster IP"),
769
+ placeholder: "192.168.x.x",
770
+ validate: (v) => { if (!v)
771
+ return "Required"; },
772
+ });
773
+ if (p.isCancel(localIp))
774
+ return back();
775
+ }
776
+ }
777
+ else {
778
+ localIp = await p.text({
779
+ message: pc.bold("Cluster IP") + pc.dim(" (127.0.0.1 for localhost, or your LAN IP)"),
780
+ defaultValue: "127.0.0.1",
781
+ });
782
+ }
783
+ if (p.isCancel(localIp))
784
+ return back();
785
+ const allowedIps = await p.text({
786
+ message: pc.bold("IPs allowed to access the cluster (CIDR)"),
787
+ defaultValue: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
788
+ placeholder: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
352
789
  });
353
- if (p.isCancel(v))
790
+ if (p.isCancel(allowedIps))
354
791
  return back();
355
- return { ...state, clusterPublicIp: v };
792
+ return { ...state, clusterPublicIp: localIp, ingressAllowedIps: allowedIps };
356
793
  },
357
- review: (state) => ["Public IP", state.clusterPublicIp],
794
+ review: (state) => ["Network", `${state.clusterPublicIp} allowed: ${state.ingressAllowedIps}`],
358
795
  },
359
796
  ];
360
797
  }
361
798
  // ---------------------------------------------------------------------------
362
799
  // Helpers
363
800
  // ---------------------------------------------------------------------------
364
- function resolveRepoRoot() {
365
- const scriptDir = new URL(".", import.meta.url).pathname;
366
- return resolve(scriptDir, "../../../");
367
- }
368
801
  // ---------------------------------------------------------------------------
369
802
  // Repo creation phase (only for "new" mode)
370
803
  // ---------------------------------------------------------------------------
371
804
  async function createAndCloneRepo(wizard) {
372
- log.step("Authenticating with GitLab");
373
- await gitlab.authenticate(wizard.gitlabPat, SOURCE_GITLAB_HOST);
805
+ const provider = await getProvider(wizard.gitProvider);
806
+ const label = providerLabel(wizard.gitProvider);
807
+ const host = provider.defaultHost;
808
+ log.step(`Authenticating with ${label}`);
809
+ await provider.authenticate(wizard.gitToken, host);
374
810
  log.step(`Resolving namespace '${wizard.repoOwner}'`);
375
- const namespaceId = await gitlab.resolveNamespaceId(wizard.repoOwner, SOURCE_GITLAB_HOST);
811
+ const namespaceId = await provider.resolveNamespaceId(wizard.repoOwner, host, wizard.gitToken);
376
812
  log.step(`Creating project ${wizard.repoOwner}/${wizard.repoName}`);
377
- const existing = await gitlab.getProject(wizard.repoOwner, wizard.repoName, SOURCE_GITLAB_HOST);
813
+ const existing = await provider.getProject(wizard.repoOwner, wizard.repoName, host, wizard.gitToken);
378
814
  let httpUrl;
379
815
  let pathWithNs;
380
816
  let repoExisted = false;
@@ -394,7 +830,7 @@ async function createAndCloneRepo(wizard) {
394
830
  log.success(`Using existing: ${pathWithNs}`);
395
831
  }
396
832
  else {
397
- const created = await withSpinner("Creating GitLab project", () => gitlab.createProject(wizard.repoName, namespaceId, SOURCE_GITLAB_HOST));
833
+ const created = await withSpinner(`Creating ${label} project`, () => provider.createProject(wizard.repoName, namespaceId, host, wizard.gitToken));
398
834
  httpUrl = created.httpUrl;
399
835
  pathWithNs = created.pathWithNamespace;
400
836
  log.success(`Created: ${pathWithNs}`);
@@ -418,10 +854,22 @@ async function createAndCloneRepo(wizard) {
418
854
  }
419
855
  }
420
856
  else {
421
- await withSpinner("Cloning template repository", () => execAsync(`git clone --quiet --branch "${wizard.repoBranch}" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
857
+ const cloneRef = wizard.templateTag || "main";
858
+ let clonedRef = cloneRef;
859
+ try {
860
+ await withSpinner(`Cloning template (${cloneRef})`, () => execAsync(`git clone --quiet --branch "${cloneRef}" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
861
+ }
862
+ catch {
863
+ log.warn(`Tag/branch '${cloneRef}' not found — falling back to 'main'`);
864
+ clonedRef = "main";
865
+ await withSpinner("Cloning template (main)", () => execAsync(`git clone --quiet --branch "main" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
866
+ }
867
+ if (clonedRef !== wizard.repoBranch) {
868
+ exec(`git checkout -B "${wizard.repoBranch}"`, { cwd: cloneDir });
869
+ }
422
870
  exec(`git remote set-url origin "${httpUrl}"`, { cwd: cloneDir });
423
871
  }
424
- const authRemote = `https://oauth2:${wizard.gitlabPat}@${SOURCE_GITLAB_HOST}/${pathWithNs}.git`;
872
+ const authRemote = provider.getAuthRemoteUrl(host, pathWithNs, wizard.gitToken);
425
873
  await withSpinner(`Pushing to ${pathWithNs}`, () => {
426
874
  const forceFlag = repoExisted ? " --force" : "";
427
875
  return execAsync(`git push -u "${authRemote}" "${wizard.repoBranch}"${forceFlag} --quiet`, { cwd: cloneDir });
@@ -481,10 +929,26 @@ export async function bootstrap() {
481
929
  }
482
930
  });
483
931
  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",
932
+ const version = readPackageVersion();
933
+ const logo = [
934
+ " ▄█████▄ ",
935
+ " ██ ◆ ██ ",
936
+ " ██ ██ ",
937
+ " ▀██ ██▀ ",
938
+ " ▀█▀ ",
939
+ ];
940
+ const taglines = [
941
+ "💅 Secure, isolated & flexible GitOps infrastructure",
942
+ "🤖 Manage it yourself — or delegate to AI",
943
+ "🔐 Encrypted secrets, hardened containers,",
944
+ " continuous delivery",
945
+ pc.dim(`v${version}`),
946
+ ];
947
+ const banner = logo
948
+ .map((l, i) => pc.cyan(l) + " " + (taglines[i] ?? ""))
949
+ .join("\n");
950
+ p.box(banner, pc.bold("Welcome to GitOps AI Bootstrapper"), {
951
+ contentAlign: "left",
488
952
  titleAlign: "center",
489
953
  rounded: true,
490
954
  formatBorder: (text) => pc.cyan(text),
@@ -495,14 +959,46 @@ export async function bootstrap() {
495
959
  log.warn("Loading saved inputs from previous run");
496
960
  }
497
961
  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");
962
+ // Backward compat: migrate gitlabPat gitToken
963
+ if (prev.gitlabPat && !prev.gitToken) {
964
+ prev.gitToken = prev.gitlabPat;
965
+ }
966
+ // ── Check for existing cluster ──────────────────────────────────────
967
+ const existing = k8s.detectExistingClusters();
968
+ if (existing) {
969
+ const clusterList = existing.names
970
+ .map((n) => ` ${pc.cyan(n)}`)
971
+ .join("\n");
972
+ const deleteHint = existing.type === "k3d"
973
+ ? ` ${pc.cyan(`k3d cluster delete ${existing.names[0]}`)}`
974
+ : ` ${pc.cyan("sudo /usr/local/bin/k3s-uninstall.sh")}`;
975
+ p.log.warn(pc.yellow(`Existing ${existing.type} cluster(s) detected:`));
976
+ p.note(`${pc.bold("Clusters found:")}\n${clusterList}\n\n` +
977
+ `Re-bootstrapping may overwrite existing resources.\n` +
978
+ `To start fresh, delete the cluster first:\n` +
979
+ deleteHint + `\n\n` +
980
+ pc.dim("Choose Continue to proceed anyway."), "Cluster Already Exists");
981
+ const shouldContinue = await p.confirm({
982
+ message: pc.bold("Continue with the existing cluster?"),
983
+ initialValue: false,
984
+ });
985
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
986
+ finish("Bootstrap cancelled — existing cluster left untouched");
987
+ return;
503
988
  }
504
- catch {
505
- detectedIp = "";
989
+ }
990
+ // ── Detect public IP (silent, try multiple services) ─────────────────
991
+ let detectedIp = "";
992
+ if (!prev.clusterPublicIp) {
993
+ for (const svc of ["ifconfig.me", "api.ipify.org", "icanhazip.com"]) {
994
+ try {
995
+ const ip = (await execAsync(`curl -s --max-time 4 ${svc}`)).trim();
996
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
997
+ detectedIp = ip;
998
+ break;
999
+ }
1000
+ }
1001
+ catch { /* try next */ }
506
1002
  }
507
1003
  }
508
1004
  // ── Run interactive wizard ───────────────────────────────────────────
@@ -514,9 +1010,11 @@ export async function bootstrap() {
514
1010
  : [
515
1011
  ...REQUIRED_COMPONENT_IDS,
516
1012
  ...(savedDnsTls ? DNS_TLS_COMPONENT_IDS : []),
1013
+ ...MONITORING_COMPONENT_IDS,
517
1014
  ...OPTIONAL_COMPONENTS.map((c) => c.id),
518
1015
  ];
519
1016
  const initialState = {
1017
+ gitProvider: prev.gitProvider ?? "github",
520
1018
  setupMode: prev.setupMode ?? "new",
521
1019
  manageDnsAndTls: savedDnsTls,
522
1020
  selectedComponents: savedComponents,
@@ -526,8 +1024,10 @@ export async function bootstrap() {
526
1024
  repoLocalPath: prev.repoLocalPath ?? "",
527
1025
  repoOwner: prev.repoOwner ?? "",
528
1026
  repoBranch: prev.repoBranch ?? "main",
1027
+ templateTag: prev.templateTag ?? "",
529
1028
  letsencryptEmail: prev.letsencryptEmail ?? "",
530
- gitlabPat: prev.gitlabPat ?? "",
1029
+ gitToken: prev.gitToken ?? "",
1030
+ gitFluxToken: prev.gitFluxToken ?? "",
531
1031
  cloudflareApiToken: prev.cloudflareApiToken ?? "",
532
1032
  openaiApiKey: prev.openaiApiKey ?? "",
533
1033
  openclawGatewayToken: prev.openclawGatewayToken ?? "",
@@ -537,6 +1037,7 @@ export async function bootstrap() {
537
1037
  const wizard = await stepWizard(buildFields(detectedIp, !!saved), initialState);
538
1038
  // ── Save config ─────────────────────────────────────────────────────
539
1039
  saveInstallPlan({
1040
+ gitProvider: wizard.gitProvider,
540
1041
  setupMode: wizard.setupMode,
541
1042
  manageDnsAndTls: String(wizard.manageDnsAndTls),
542
1043
  clusterName: wizard.clusterName,
@@ -544,11 +1045,13 @@ export async function bootstrap() {
544
1045
  clusterPublicIp: wizard.clusterPublicIp,
545
1046
  letsencryptEmail: wizard.letsencryptEmail,
546
1047
  ingressAllowedIps: wizard.ingressAllowedIps,
547
- gitlabPat: wizard.gitlabPat,
1048
+ gitToken: wizard.gitToken,
1049
+ gitFluxToken: wizard.gitFluxToken,
548
1050
  repoName: wizard.repoName,
549
1051
  repoLocalPath: wizard.repoLocalPath,
550
1052
  repoOwner: wizard.repoOwner,
551
1053
  repoBranch: wizard.repoBranch,
1054
+ templateTag: wizard.templateTag,
552
1055
  cloudflareApiToken: wizard.cloudflareApiToken,
553
1056
  openaiApiKey: wizard.openaiApiKey ?? "",
554
1057
  openclawGatewayToken: wizard.openclawGatewayToken ?? "",
@@ -558,11 +1061,8 @@ export async function bootstrap() {
558
1061
  // ── Warn about CLI tools that will be installed ─────────────────────
559
1062
  const toolDescriptions = [
560
1063
  ["git", "Version control (repo operations)"],
561
- ["jq", "JSON processor (API responses)"],
562
- ["glab", "GitLab CLI (repo & auth management)"],
563
1064
  ["kubectl", "Kubernetes CLI (cluster management)"],
564
1065
  ["helm", "Kubernetes package manager (chart installs)"],
565
- ["k9s", "Terminal UI for Kubernetes (monitoring)"],
566
1066
  ["flux-operator", "FluxCD Operator CLI (GitOps reconciliation)"],
567
1067
  ["sops", "Mozilla SOPS (secret encryption)"],
568
1068
  ["age", "Age encryption (SOPS key backend)"],
@@ -589,12 +1089,12 @@ export async function bootstrap() {
589
1089
  pc.dim("─".repeat(60)) + "\n\n" +
590
1090
  pc.bold("Why are these needed?\n") +
591
1091
  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") +
1092
+ pc.dim(`deploy components via Helm/Flux, encrypt secrets, and interact with ${providerLabel(wizard.gitProvider)}.\n\n`) +
593
1093
  pc.bold("How to uninstall later:\n") +
594
1094
  (isMacOS()
595
1095
  ? ` ${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`) +
1096
+ : ` ${pc.cyan("sudo rm -f /usr/local/bin/{kubectl,helm,flux-operator,sops,age,age-keygen}")}\n` +
1097
+ ` ${pc.cyan(`sudo apt remove -y git`)} ${pc.dim("(if installed via apt)")}\n`) +
598
1098
  pc.dim("\nAlready-installed tools will be skipped. No system tools will be modified."), "Required CLI Tools");
599
1099
  const confirmMsg = toBeInstalled.length > 0
600
1100
  ? `Install ${toBeInstalled.length} missing tool(s) and continue?`
@@ -625,18 +1125,20 @@ export async function bootstrap() {
625
1125
  }
626
1126
  }
627
1127
  else {
628
- repoRoot = resolveRepoRoot();
1128
+ repoRoot = resolve(wizard.repoLocalPath || ".");
629
1129
  }
630
1130
  // ── Build final config ───────────────────────────────────────────────
631
1131
  const selectedComponents = wizard.selectedComponents;
632
1132
  const isOpenclawEnabled = openclawEnabled(wizard);
633
1133
  const fullConfig = {
1134
+ gitProvider: wizard.gitProvider,
634
1135
  clusterName: wizard.clusterName,
635
1136
  clusterDomain: wizard.clusterDomain,
636
1137
  clusterPublicIp: wizard.clusterPublicIp,
637
1138
  letsencryptEmail: wizard.letsencryptEmail,
638
1139
  ingressAllowedIps: wizard.ingressAllowedIps,
639
- gitlabPat: wizard.gitlabPat,
1140
+ gitToken: wizard.gitToken,
1141
+ gitFluxToken: wizard.gitFluxToken || undefined,
640
1142
  repoName: wizard.repoName,
641
1143
  repoOwner: wizard.repoOwner,
642
1144
  repoBranch: wizard.repoBranch,
@@ -646,6 +1148,8 @@ export async function bootstrap() {
646
1148
  ? wizard.openclawGatewayToken
647
1149
  : undefined,
648
1150
  selectedComponents,
1151
+ templateRef: wizard.templateTag?.trim() ||
1152
+ (isNewRepo(wizard) ? "main" : undefined),
649
1153
  };
650
1154
  // ── Check macOS prerequisites ────────────────────────────────────────
651
1155
  if (isMacOS()) {
@@ -666,19 +1170,25 @@ export async function bootstrap() {
666
1170
  log.error(`Bootstrap failed\n${formatError(err)}`);
667
1171
  return process.exit(1);
668
1172
  }
669
- // ── /etc/hosts suggestion (no DNS management) ───────────────────────
670
- if (!wizard.manageDnsAndTls) {
1173
+ // ── /etc/hosts suggestion (local IP or no DNS management) ───────────
1174
+ const isLocalIp = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(fullConfig.clusterPublicIp)
1175
+ || fullConfig.clusterPublicIp === "localhost";
1176
+ if (isLocalIp || !wizard.manageDnsAndTls) {
671
1177
  const hostsEntries = selectedComponents
672
1178
  .map((id) => COMPONENTS.find((c) => c.id === id))
673
1179
  .filter((c) => !!c?.subdomain)
674
1180
  .map((c) => `${fullConfig.clusterPublicIp} ${c.subdomain}.${fullConfig.clusterDomain}`);
675
1181
  if (hostsEntries.length > 0) {
676
1182
  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` +
1183
+ const reason = isLocalIp
1184
+ ? "Your cluster uses a local/private IP, so DNS won't resolve publicly."
1185
+ : "Automatic DNS is disabled.";
1186
+ p.note(`${pc.dim(reason + " Add these to")} ${pc.bold("/etc/hosts")}${pc.dim(":")}\n\n` +
678
1187
  hostsEntries.map((e) => pc.cyan(e)).join("\n"), "Local DNS");
679
1188
  const addHosts = await p.confirm({
680
- message: pc.bold("Append these entries to /etc/hosts now?"),
681
- initialValue: false,
1189
+ message: pc.bold("Append these entries to /etc/hosts now?") +
1190
+ pc.dim(" (requires sudo — macOS will prompt for your password)"),
1191
+ initialValue: true,
682
1192
  });
683
1193
  if (!p.isCancel(addHosts) && addHosts) {
684
1194
  try {
@@ -708,8 +1218,22 @@ export async function bootstrap() {
708
1218
  summary("Bootstrap Complete", summaryEntries);
709
1219
  const finalSteps = [
710
1220
  `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")}`,
1221
+ `Check status: ${pc.cyan("kubectl get helmreleases -A")}`,
712
1222
  ];
1223
+ if (!commandExists("k9s")) {
1224
+ finalSteps.push(`Install ${pc.bold("k9s")} for a terminal UI to monitor your cluster: ${isMacOS()
1225
+ ? pc.cyan("brew install derailed/k9s/k9s")
1226
+ : pc.cyan("https://k9scli.io/topics/install/")}`);
1227
+ }
1228
+ else {
1229
+ finalSteps.push(`Monitor your cluster: ${pc.cyan("k9s -A")}`);
1230
+ }
1231
+ if (selectedComponents.includes("grafana-operator")) {
1232
+ finalSteps.push(`Grafana dashboard: ${pc.cyan(`https://grafana.${fullConfig.clusterDomain}`)}`);
1233
+ }
1234
+ if (selectedComponents.includes("victoria-metrics-k8s-stack")) {
1235
+ finalSteps.push(`Victoria Metrics: ${pc.cyan(`https://victoria.${fullConfig.clusterDomain}`)}`);
1236
+ }
713
1237
  if (isOpenclawEnabled) {
714
1238
  finalSteps.push(`Open OpenClaw at ${pc.cyan(`https://openclaw.${fullConfig.clusterDomain}`)}`, `Pair a device: ${pc.cyan("npx fluxcd-ai-bootstraper openclaw-pair")}`);
715
1239
  }