gitops-ai 1.0.0 → 1.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.
@@ -1,17 +1,33 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
2
3
  import { resolve } from "node:path";
3
4
  import * as p from "@clack/prompts";
4
5
  import pc from "picocolors";
5
6
  import { header, log, summary, nextSteps, finish, handleCancel, withSpinner, formatError, } from "../utils/log.js";
6
7
  import { saveInstallPlan, loadInstallPlan, clearInstallPlan } from "../utils/config.js";
7
- import { execAsync, exec, commandExists } from "../utils/shell.js";
8
+ import { execAsync, exec, execSafe, commandExists } from "../utils/shell.js";
8
9
  import { isMacOS, isCI } from "../utils/platform.js";
9
10
  import { ensureAll } from "../core/dependencies.js";
10
11
  import { runBootstrap } from "../core/bootstrap-runner.js";
12
+ import * as k8s from "../core/kubernetes.js";
11
13
  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 { getProvider, } from "../core/git-provider.js";
15
+ import { COMPONENTS, REQUIRED_COMPONENT_IDS, DNS_TLS_COMPONENT_IDS, MONITORING_COMPONENT_IDS, OPTIONAL_COMPONENTS, SOURCE_GITLAB_HOST, SOURCE_PROJECT_PATH, } from "../schemas.js";
14
16
  import { stepWizard, back, maskSecret, } from "../utils/wizard.js";
17
+ import { loginAndCreateCloudflareToken } from "../core/cloudflare-oauth.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Browser opener
20
+ // ---------------------------------------------------------------------------
21
+ function openUrl(url) {
22
+ const cmd = isMacOS() ? "open" : "xdg-open";
23
+ try {
24
+ execSync(`${cmd} '${url}'`, { stdio: "ignore" });
25
+ }
26
+ catch {
27
+ /* user will see the manual URL in the terminal */
28
+ }
29
+ }
30
+ const OPENAI_API_KEYS_URL = "https://platform.openai.com/api-keys";
15
31
  function isNewRepo(state) {
16
32
  return state.setupMode === "new";
17
33
  }
@@ -24,6 +40,38 @@ function openclawEnabled(state) {
24
40
  function componentLabel(id) {
25
41
  return COMPONENTS.find((c) => c.id === id)?.label ?? id;
26
42
  }
43
+ async function fetchTemplateTags() {
44
+ const encoded = encodeURIComponent(SOURCE_PROJECT_PATH);
45
+ const url = `https://${SOURCE_GITLAB_HOST}/api/v4/projects/${encoded}/repository/tags?per_page=50&order_by=version&sort=desc`;
46
+ try {
47
+ const res = await fetch(url);
48
+ if (!res.ok)
49
+ return [];
50
+ const tags = (await res.json());
51
+ return tags.map((t) => t.name);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ function providerLabel(type) {
58
+ return type === "gitlab" ? "GitLab" : "GitHub";
59
+ }
60
+ async function enrichWithUser(state, token, provider) {
61
+ try {
62
+ const user = await provider.fetchCurrentUser(token, provider.defaultHost);
63
+ log.success(`Logged in as ${user.username}`);
64
+ return {
65
+ ...state,
66
+ gitToken: token,
67
+ repoOwner: state.repoOwner || user.username,
68
+ };
69
+ }
70
+ catch (err) {
71
+ log.warn(`Could not detect ${providerLabel(state.gitProvider)} user: ${err.message}`);
72
+ return { ...state, gitToken: token };
73
+ }
74
+ }
27
75
  // ---------------------------------------------------------------------------
28
76
  // Wizard field definitions (Esc / Ctrl+C = go back one field)
29
77
  // ---------------------------------------------------------------------------
@@ -63,32 +111,140 @@ function buildFields(detectedIp, hasSavedPlan) {
63
111
  : "Use existing repo",
64
112
  ],
65
113
  },
66
- // ── GitLab Repository ───────────────────────────────────────────────
114
+ // ── Git Provider ────────────────────────────────────────────────────
67
115
  {
68
- id: "gitlabPat",
69
- section: "GitLab Repository",
70
- skip: (state) => !!state.gitlabPat,
116
+ id: "gitProvider",
117
+ section: "Git Provider",
118
+ skip: (state) => saved(state, "gitProvider"),
71
119
  run: async (state) => {
120
+ const v = await p.select({
121
+ message: pc.bold("Which Git provider do you want to use?"),
122
+ options: [
123
+ {
124
+ value: "github",
125
+ label: "GitHub",
126
+ hint: "github.com or GitHub Enterprise",
127
+ },
128
+ {
129
+ value: "gitlab",
130
+ label: "GitLab",
131
+ hint: "gitlab.com or self-hosted",
132
+ },
133
+ ],
134
+ initialValue: state.gitProvider,
135
+ });
136
+ if (p.isCancel(v))
137
+ return back();
138
+ return { ...state, gitProvider: v };
139
+ },
140
+ review: (state) => ["Provider", providerLabel(state.gitProvider)],
141
+ },
142
+ // ── Git Repository ──────────────────────────────────────────────────
143
+ {
144
+ id: "gitToken",
145
+ section: "Git Repository",
146
+ skip: (state) => !!state.gitToken,
147
+ run: async (state) => {
148
+ const provider = await getProvider(state.gitProvider);
149
+ if (isCI()) {
150
+ const v = await p.password({
151
+ message: pc.bold(provider.tokenLabel),
152
+ validate: (v) => { if (!v)
153
+ return "Required"; },
154
+ });
155
+ if (p.isCancel(v))
156
+ return back();
157
+ return { ...state, gitToken: v };
158
+ }
159
+ const method = await p.select({
160
+ message: pc.bold(`How would you like to authenticate with ${providerLabel(state.gitProvider)}?`),
161
+ options: [
162
+ {
163
+ value: "browser",
164
+ label: "Login with browser",
165
+ hint: `opens ${providerLabel(state.gitProvider)} in your browser — recommended`,
166
+ },
167
+ {
168
+ value: "pat",
169
+ label: "Paste a Personal Access Token",
170
+ hint: "manual token entry",
171
+ },
172
+ ],
173
+ });
174
+ if (p.isCancel(method))
175
+ return back();
176
+ if (method === "browser") {
177
+ try {
178
+ const token = await provider.loginWithBrowser(provider.defaultHost);
179
+ log.success("Authenticated via browser");
180
+ return await enrichWithUser(state, token, provider);
181
+ }
182
+ catch (err) {
183
+ log.warn(`Browser login failed: ${err.message}`);
184
+ log.warn("Falling back to manual token entry");
185
+ }
186
+ }
72
187
  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
- },
188
+ message: pc.bold(provider.tokenLabel),
189
+ validate: (v) => { if (!v)
190
+ return "Required"; },
78
191
  });
79
192
  if (p.isCancel(v))
80
193
  return back();
81
- return { ...state, gitlabPat: v };
194
+ return await enrichWithUser(state, v, provider);
82
195
  },
83
- review: (state) => ["PAT", maskSecret(state.gitlabPat)],
196
+ review: (state) => ["Token", maskSecret(state.gitToken)],
84
197
  },
85
198
  {
86
199
  id: "repoOwner",
87
- section: "GitLab Repository",
88
- skip: (state) => saved(state, "repoOwner"),
200
+ section: "Git Repository",
201
+ skip: (state) => !!state.repoOwner,
89
202
  run: async (state) => {
203
+ const provider = await getProvider(state.gitProvider);
204
+ const label = providerLabel(state.gitProvider);
205
+ const orgLabel = state.gitProvider === "gitlab" ? "group" : "org";
206
+ try {
207
+ const user = await provider.fetchCurrentUser(state.gitToken, provider.defaultHost);
208
+ let orgs = [];
209
+ try {
210
+ orgs = await provider.fetchOrganizations(state.gitToken, provider.defaultHost);
211
+ }
212
+ catch {
213
+ /* no orgs or insufficient permissions — continue with personal only */
214
+ }
215
+ const options = [
216
+ {
217
+ value: user.username,
218
+ label: user.username,
219
+ hint: `personal · ${user.name}`,
220
+ },
221
+ ...orgs.map((g) => ({
222
+ value: g.fullPath,
223
+ label: g.fullPath,
224
+ hint: orgLabel,
225
+ })),
226
+ {
227
+ value: "__manual__",
228
+ label: "Enter manually…",
229
+ hint: "type a namespace",
230
+ },
231
+ ];
232
+ const v = await p.select({
233
+ message: pc.bold(`${label} namespace for the repository`),
234
+ options,
235
+ initialValue: state.repoOwner || user.username,
236
+ });
237
+ if (p.isCancel(v))
238
+ return back();
239
+ if (v !== "__manual__") {
240
+ return { ...state, repoOwner: v };
241
+ }
242
+ }
243
+ catch {
244
+ /* API unavailable — fall through to manual input */
245
+ }
90
246
  const v = await p.text({
91
- message: pc.bold("GitLab repo owner / namespace (without @)"),
247
+ message: pc.bold(`${label} repo owner / namespace`),
92
248
  placeholder: "my-username-or-group",
93
249
  initialValue: state.repoOwner || undefined,
94
250
  validate: (v) => {
@@ -104,13 +260,54 @@ function buildFields(detectedIp, hasSavedPlan) {
104
260
  },
105
261
  {
106
262
  id: "repoName",
107
- section: "GitLab Repository",
263
+ section: "Git Repository",
108
264
  skip: (state) => saved(state, "repoName"),
109
265
  run: async (state) => {
266
+ if (isNewRepo(state)) {
267
+ const v = await p.text({
268
+ message: pc.bold("New repository name"),
269
+ placeholder: "fluxcd_ai",
270
+ defaultValue: state.repoName,
271
+ });
272
+ if (p.isCancel(v))
273
+ return back();
274
+ return { ...state, repoName: v };
275
+ }
276
+ const provider = await getProvider(state.gitProvider);
277
+ try {
278
+ const projects = await provider.fetchNamespaceProjects(state.gitToken, provider.defaultHost, state.repoOwner);
279
+ if (projects.length > 0) {
280
+ const options = [
281
+ ...projects.map((proj) => ({
282
+ value: proj.name,
283
+ label: proj.name,
284
+ hint: proj.description
285
+ ? proj.description.slice(0, 60)
286
+ : undefined,
287
+ })),
288
+ {
289
+ value: "__manual__",
290
+ label: "Enter manually…",
291
+ hint: "type a repo name",
292
+ },
293
+ ];
294
+ const v = await p.select({
295
+ message: pc.bold("Select repository"),
296
+ options,
297
+ initialValue: state.repoName || undefined,
298
+ });
299
+ if (p.isCancel(v))
300
+ return back();
301
+ if (v !== "__manual__") {
302
+ return { ...state, repoName: v };
303
+ }
304
+ }
305
+ }
306
+ catch {
307
+ /* fall through to manual input */
308
+ }
110
309
  const v = await p.text({
111
- message: isNewRepo(state)
112
- ? pc.bold("New repository name")
113
- : pc.bold("Flux GitLab repo name"),
310
+ message: pc.bold("Flux repo name"),
114
311
  placeholder: "fluxcd_ai",
115
312
  defaultValue: state.repoName,
116
313
  });
@@ -122,30 +319,48 @@ function buildFields(detectedIp, hasSavedPlan) {
122
319
  },
123
320
  {
124
321
  id: "repoLocalPath",
125
- section: "GitLab Repository",
126
- hidden: (state) => !isNewRepo(state),
322
+ section: "Git Repository",
127
323
  skip: (state) => saved(state, "repoLocalPath"),
128
324
  run: async (state) => {
325
+ if (isNewRepo(state)) {
326
+ const v = await p.text({
327
+ message: pc.bold("Local directory to clone into"),
328
+ placeholder: `./${state.repoName} (relative to current directory)`,
329
+ defaultValue: state.repoLocalPath || state.repoName,
330
+ });
331
+ if (p.isCancel(v))
332
+ return back();
333
+ return { ...state, repoLocalPath: v };
334
+ }
129
335
  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,
336
+ message: pc.bold("Path to your local repo checkout"),
337
+ placeholder: `./${state.repoName} (relative or absolute path)`,
338
+ defaultValue: state.repoLocalPath || ".",
339
+ validate: (val) => {
340
+ const target = resolve(val || ".");
341
+ if (!existsSync(target))
342
+ return "Directory does not exist";
343
+ const gitCheck = execSafe("git rev-parse --is-inside-work-tree", { cwd: target });
344
+ if (gitCheck.exitCode !== 0)
345
+ return "Not a git repository — run git init or clone first";
346
+ return undefined;
347
+ },
133
348
  });
134
349
  if (p.isCancel(v))
135
350
  return back();
136
351
  return { ...state, repoLocalPath: v };
137
352
  },
138
- review: (state) => ["Local path", `./${state.repoLocalPath}`],
353
+ review: (state) => isNewRepo(state)
354
+ ? ["Local path", `./${state.repoLocalPath}`]
355
+ : ["Local repo path", resolve(state.repoLocalPath || ".")],
139
356
  },
140
357
  {
141
358
  id: "repoBranch",
142
- section: "GitLab Repository",
359
+ section: "Git Repository",
143
360
  skip: (state) => saved(state, "repoBranch"),
144
361
  run: async (state) => {
145
362
  const v = await p.text({
146
- message: isNewRepo(state)
147
- ? pc.bold("Template branch name to clone")
148
- : pc.bold("Git branch for Flux"),
363
+ message: pc.bold("Git branch for Flux"),
149
364
  placeholder: "main",
150
365
  defaultValue: state.repoBranch,
151
366
  });
@@ -155,6 +370,48 @@ function buildFields(detectedIp, hasSavedPlan) {
155
370
  },
156
371
  review: (state) => ["Branch", state.repoBranch],
157
372
  },
373
+ {
374
+ id: "templateTag",
375
+ section: "Git Repository",
376
+ hidden: (state) => !isNewRepo(state),
377
+ skip: (state) => saved(state, "templateTag"),
378
+ run: async (state) => {
379
+ const tags = await fetchTemplateTags();
380
+ if (tags.length > 0) {
381
+ const options = [
382
+ ...tags.map((tag, i) => ({
383
+ value: tag,
384
+ label: tag,
385
+ hint: i === 0 ? "latest" : undefined,
386
+ })),
387
+ {
388
+ value: "__manual__",
389
+ label: "Enter manually…",
390
+ hint: "type a tag or branch name",
391
+ },
392
+ ];
393
+ const v = await p.select({
394
+ message: pc.bold("Template version (tag) to clone"),
395
+ options,
396
+ initialValue: state.templateTag || tags[0],
397
+ });
398
+ if (p.isCancel(v))
399
+ return back();
400
+ if (v !== "__manual__") {
401
+ return { ...state, templateTag: v };
402
+ }
403
+ }
404
+ const v = await p.text({
405
+ message: pc.bold("Template tag or branch to clone"),
406
+ placeholder: "main",
407
+ defaultValue: state.templateTag || "main",
408
+ });
409
+ if (p.isCancel(v))
410
+ return back();
411
+ return { ...state, templateTag: v };
412
+ },
413
+ review: (state) => ["Template tag", state.templateTag],
414
+ },
158
415
  // ── DNS & TLS ─────────────────────────────────────────────────────────
159
416
  {
160
417
  id: "manageDnsAndTls",
@@ -183,25 +440,42 @@ function buildFields(detectedIp, hasSavedPlan) {
183
440
  section: "Components",
184
441
  skip: (state) => saved(state, "selectedComponents"),
185
442
  run: async (state) => {
443
+ const MONITORING_GROUP_ID = "__monitoring__";
444
+ const monitoringOption = {
445
+ value: MONITORING_GROUP_ID,
446
+ label: "Monitoring Stack",
447
+ hint: "Victoria Metrics + Grafana Operator (dashboards, alerting, metrics)",
448
+ };
449
+ const hasMonitoring = MONITORING_COMPONENT_IDS.every((id) => state.selectedComponents.includes(id));
186
450
  const selected = await p.multiselect({
187
451
  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)),
452
+ options: [
453
+ monitoringOption,
454
+ ...OPTIONAL_COMPONENTS.map((c) => ({
455
+ value: c.id,
456
+ label: c.label,
457
+ hint: c.hint,
458
+ })),
459
+ ],
460
+ initialValues: [
461
+ ...(hasMonitoring ? [MONITORING_GROUP_ID] : []),
462
+ ...state.selectedComponents.filter((id) => OPTIONAL_COMPONENTS.some((c) => c.id === id)),
463
+ ],
194
464
  required: false,
195
465
  });
196
466
  if (p.isCancel(selected))
197
467
  return back();
468
+ const picks = selected;
469
+ const monitoringIds = picks.includes(MONITORING_GROUP_ID) ? MONITORING_COMPONENT_IDS : [];
470
+ const otherIds = picks.filter((id) => id !== MONITORING_GROUP_ID);
198
471
  const dnsTlsIds = state.manageDnsAndTls ? DNS_TLS_COMPONENT_IDS : [];
199
472
  return {
200
473
  ...state,
201
474
  selectedComponents: [
202
475
  ...REQUIRED_COMPONENT_IDS,
203
476
  ...dnsTlsIds,
204
- ...selected,
477
+ ...monitoringIds,
478
+ ...otherIds,
205
479
  ],
206
480
  };
207
481
  },
@@ -274,12 +548,47 @@ function buildFields(detectedIp, hasSavedPlan) {
274
548
  hidden: (state) => !dnsAndTlsEnabled(state),
275
549
  skip: (state) => !!state.cloudflareApiToken,
276
550
  run: async (state) => {
551
+ if (isCI()) {
552
+ const v = await p.password({
553
+ message: pc.bold("Cloudflare API Token (DNS zone edit access)"),
554
+ validate: (v) => { if (!v)
555
+ return "Required"; },
556
+ });
557
+ if (p.isCancel(v))
558
+ return back();
559
+ return { ...state, cloudflareApiToken: v };
560
+ }
561
+ const method = await p.select({
562
+ message: pc.bold("How would you like to authenticate with Cloudflare?"),
563
+ options: [
564
+ {
565
+ value: "browser",
566
+ label: "Login with browser",
567
+ hint: "OAuth login → auto-creates a scoped DNS token — recommended",
568
+ },
569
+ {
570
+ value: "pat",
571
+ label: "Paste an API Token",
572
+ hint: "manual token from dash.cloudflare.com",
573
+ },
574
+ ],
575
+ });
576
+ if (p.isCancel(method))
577
+ return back();
578
+ if (method === "browser") {
579
+ try {
580
+ const token = await loginAndCreateCloudflareToken(state.clusterDomain);
581
+ return { ...state, cloudflareApiToken: token };
582
+ }
583
+ catch (err) {
584
+ log.warn(`Browser login failed: ${err.message}`);
585
+ log.warn("Falling back to manual token entry");
586
+ }
587
+ }
277
588
  const v = await p.password({
278
589
  message: pc.bold("Cloudflare API Token (DNS zone edit access)"),
279
- validate: (v) => {
280
- if (!v)
281
- return "Required";
282
- },
590
+ validate: (v) => { if (!v)
591
+ return "Required"; },
283
592
  });
284
593
  if (p.isCancel(v))
285
594
  return back();
@@ -296,12 +605,48 @@ function buildFields(detectedIp, hasSavedPlan) {
296
605
  hidden: (state) => !openclawEnabled(state),
297
606
  skip: (state) => !!state.openaiApiKey,
298
607
  run: async (state) => {
608
+ if (isCI()) {
609
+ const v = await p.password({
610
+ message: pc.bold("OpenAI API Key"),
611
+ validate: (v) => { if (!v)
612
+ return "Required"; },
613
+ });
614
+ if (p.isCancel(v))
615
+ return back();
616
+ const openclawGatewayToken = state.openclawGatewayToken || exec("openssl rand -hex 32");
617
+ return { ...state, openaiApiKey: v, openclawGatewayToken };
618
+ }
619
+ const method = await p.select({
620
+ message: pc.bold("How would you like to provide your OpenAI API key?"),
621
+ options: [
622
+ {
623
+ value: "browser",
624
+ label: "Open dashboard in browser",
625
+ hint: "opens platform.openai.com to create a key — recommended",
626
+ },
627
+ {
628
+ value: "paste",
629
+ label: "Paste an API Key",
630
+ hint: "manual key entry",
631
+ },
632
+ ],
633
+ });
634
+ if (p.isCancel(method))
635
+ return back();
636
+ if (method === "browser") {
637
+ p.note(`${pc.bold("Create an API key with these steps:")}\n\n` +
638
+ ` 1. Log in to ${pc.cyan("platform.openai.com")}\n` +
639
+ ` 2. Click ${pc.cyan("+ Create new secret key")}\n` +
640
+ ` 3. Name it (e.g. ${pc.cyan("gitops-ai")})\n` +
641
+ ` 4. Copy the key value\n\n` +
642
+ pc.dim("The key starts with sk-… and is only shown once."), "OpenAI API Key");
643
+ p.log.info(pc.dim("Opening browser…"));
644
+ openUrl(OPENAI_API_KEYS_URL);
645
+ }
299
646
  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
- },
647
+ message: pc.bold("Paste the OpenAI API key"),
648
+ validate: (v) => { if (!v)
649
+ return "Required"; },
305
650
  });
306
651
  if (p.isCancel(v))
307
652
  return back();
@@ -334,11 +679,11 @@ function buildFields(detectedIp, hasSavedPlan) {
334
679
  {
335
680
  id: "clusterPublicIp",
336
681
  section: "Network",
337
- skip: (state) => saved(state, "clusterPublicIp"),
682
+ skip: (state) => dnsAndTlsEnabled(state) && saved(state, "clusterPublicIp"),
338
683
  run: async (state) => {
339
684
  const useLocal = !dnsAndTlsEnabled(state);
340
685
  const fallback = useLocal ? "127.0.0.1" : detectedIp;
341
- const defaultIp = state.clusterPublicIp || fallback;
686
+ const defaultIp = useLocal ? fallback : (state.clusterPublicIp || fallback);
342
687
  const v = await p.text({
343
688
  message: useLocal
344
689
  ? pc.bold("Cluster IP") + pc.dim(" (local because DNS management is disabled. Rewrite if it necessary)")
@@ -361,20 +706,19 @@ function buildFields(detectedIp, hasSavedPlan) {
361
706
  // ---------------------------------------------------------------------------
362
707
  // Helpers
363
708
  // ---------------------------------------------------------------------------
364
- function resolveRepoRoot() {
365
- const scriptDir = new URL(".", import.meta.url).pathname;
366
- return resolve(scriptDir, "../../../");
367
- }
368
709
  // ---------------------------------------------------------------------------
369
710
  // Repo creation phase (only for "new" mode)
370
711
  // ---------------------------------------------------------------------------
371
712
  async function createAndCloneRepo(wizard) {
372
- log.step("Authenticating with GitLab");
373
- await gitlab.authenticate(wizard.gitlabPat, SOURCE_GITLAB_HOST);
713
+ const provider = await getProvider(wizard.gitProvider);
714
+ const label = providerLabel(wizard.gitProvider);
715
+ const host = provider.defaultHost;
716
+ log.step(`Authenticating with ${label}`);
717
+ await provider.authenticate(wizard.gitToken, host);
374
718
  log.step(`Resolving namespace '${wizard.repoOwner}'`);
375
- const namespaceId = await gitlab.resolveNamespaceId(wizard.repoOwner, SOURCE_GITLAB_HOST);
719
+ const namespaceId = await provider.resolveNamespaceId(wizard.repoOwner, host, wizard.gitToken);
376
720
  log.step(`Creating project ${wizard.repoOwner}/${wizard.repoName}`);
377
- const existing = await gitlab.getProject(wizard.repoOwner, wizard.repoName, SOURCE_GITLAB_HOST);
721
+ const existing = await provider.getProject(wizard.repoOwner, wizard.repoName, host, wizard.gitToken);
378
722
  let httpUrl;
379
723
  let pathWithNs;
380
724
  let repoExisted = false;
@@ -394,7 +738,7 @@ async function createAndCloneRepo(wizard) {
394
738
  log.success(`Using existing: ${pathWithNs}`);
395
739
  }
396
740
  else {
397
- const created = await withSpinner("Creating GitLab project", () => gitlab.createProject(wizard.repoName, namespaceId, SOURCE_GITLAB_HOST));
741
+ const created = await withSpinner(`Creating ${label} project`, () => provider.createProject(wizard.repoName, namespaceId, host, wizard.gitToken));
398
742
  httpUrl = created.httpUrl;
399
743
  pathWithNs = created.pathWithNamespace;
400
744
  log.success(`Created: ${pathWithNs}`);
@@ -418,10 +762,22 @@ async function createAndCloneRepo(wizard) {
418
762
  }
419
763
  }
420
764
  else {
421
- await withSpinner("Cloning template repository", () => execAsync(`git clone --quiet --branch "${wizard.repoBranch}" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
765
+ const cloneRef = wizard.templateTag || "main";
766
+ let clonedRef = cloneRef;
767
+ try {
768
+ await withSpinner(`Cloning template (${cloneRef})`, () => execAsync(`git clone --quiet --branch "${cloneRef}" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
769
+ }
770
+ catch {
771
+ log.warn(`Tag/branch '${cloneRef}' not found — falling back to 'main'`);
772
+ clonedRef = "main";
773
+ await withSpinner("Cloning template (main)", () => execAsync(`git clone --quiet --branch "main" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
774
+ }
775
+ if (clonedRef !== wizard.repoBranch) {
776
+ exec(`git checkout -B "${wizard.repoBranch}"`, { cwd: cloneDir });
777
+ }
422
778
  exec(`git remote set-url origin "${httpUrl}"`, { cwd: cloneDir });
423
779
  }
424
- const authRemote = `https://oauth2:${wizard.gitlabPat}@${SOURCE_GITLAB_HOST}/${pathWithNs}.git`;
780
+ const authRemote = provider.getAuthRemoteUrl(host, pathWithNs, wizard.gitToken);
425
781
  await withSpinner(`Pushing to ${pathWithNs}`, () => {
426
782
  const forceFlag = repoExisted ? " --force" : "";
427
783
  return execAsync(`git push -u "${authRemote}" "${wizard.repoBranch}"${forceFlag} --quiet`, { cwd: cloneDir });
@@ -495,6 +851,34 @@ export async function bootstrap() {
495
851
  log.warn("Loading saved inputs from previous run");
496
852
  }
497
853
  const prev = (saved ?? {});
854
+ // Backward compat: migrate gitlabPat → gitToken
855
+ if (prev.gitlabPat && !prev.gitToken) {
856
+ prev.gitToken = prev.gitlabPat;
857
+ }
858
+ // ── Check for existing cluster ──────────────────────────────────────
859
+ const existing = k8s.detectExistingClusters();
860
+ if (existing) {
861
+ const clusterList = existing.names
862
+ .map((n) => ` ${pc.cyan(n)}`)
863
+ .join("\n");
864
+ const deleteHint = existing.type === "k3d"
865
+ ? ` ${pc.cyan(`k3d cluster delete ${existing.names[0]}`)}`
866
+ : ` ${pc.cyan("sudo /usr/local/bin/k3s-uninstall.sh")}`;
867
+ p.log.warn(pc.yellow(`Existing ${existing.type} cluster(s) detected:`));
868
+ p.note(`${pc.bold("Clusters found:")}\n${clusterList}\n\n` +
869
+ `Re-bootstrapping may overwrite existing resources.\n` +
870
+ `To start fresh, delete the cluster first:\n` +
871
+ deleteHint + `\n\n` +
872
+ pc.dim("Choose Continue to proceed anyway."), "Cluster Already Exists");
873
+ const shouldContinue = await p.confirm({
874
+ message: pc.bold("Continue with the existing cluster?"),
875
+ initialValue: false,
876
+ });
877
+ if (p.isCancel(shouldContinue) || !shouldContinue) {
878
+ finish("Bootstrap cancelled — existing cluster left untouched");
879
+ return;
880
+ }
881
+ }
498
882
  // ── Detect public IP (silent) ────────────────────────────────────────
499
883
  let detectedIp = prev.clusterPublicIp ?? "";
500
884
  if (!detectedIp) {
@@ -517,6 +901,7 @@ export async function bootstrap() {
517
901
  ...OPTIONAL_COMPONENTS.map((c) => c.id),
518
902
  ];
519
903
  const initialState = {
904
+ gitProvider: prev.gitProvider ?? "github",
520
905
  setupMode: prev.setupMode ?? "new",
521
906
  manageDnsAndTls: savedDnsTls,
522
907
  selectedComponents: savedComponents,
@@ -526,8 +911,10 @@ export async function bootstrap() {
526
911
  repoLocalPath: prev.repoLocalPath ?? "",
527
912
  repoOwner: prev.repoOwner ?? "",
528
913
  repoBranch: prev.repoBranch ?? "main",
914
+ templateTag: prev.templateTag ?? "",
529
915
  letsencryptEmail: prev.letsencryptEmail ?? "",
530
- gitlabPat: prev.gitlabPat ?? "",
916
+ gitToken: prev.gitToken ?? "",
917
+ gitFluxToken: prev.gitFluxToken ?? "",
531
918
  cloudflareApiToken: prev.cloudflareApiToken ?? "",
532
919
  openaiApiKey: prev.openaiApiKey ?? "",
533
920
  openclawGatewayToken: prev.openclawGatewayToken ?? "",
@@ -537,6 +924,7 @@ export async function bootstrap() {
537
924
  const wizard = await stepWizard(buildFields(detectedIp, !!saved), initialState);
538
925
  // ── Save config ─────────────────────────────────────────────────────
539
926
  saveInstallPlan({
927
+ gitProvider: wizard.gitProvider,
540
928
  setupMode: wizard.setupMode,
541
929
  manageDnsAndTls: String(wizard.manageDnsAndTls),
542
930
  clusterName: wizard.clusterName,
@@ -544,11 +932,13 @@ export async function bootstrap() {
544
932
  clusterPublicIp: wizard.clusterPublicIp,
545
933
  letsencryptEmail: wizard.letsencryptEmail,
546
934
  ingressAllowedIps: wizard.ingressAllowedIps,
547
- gitlabPat: wizard.gitlabPat,
935
+ gitToken: wizard.gitToken,
936
+ gitFluxToken: wizard.gitFluxToken,
548
937
  repoName: wizard.repoName,
549
938
  repoLocalPath: wizard.repoLocalPath,
550
939
  repoOwner: wizard.repoOwner,
551
940
  repoBranch: wizard.repoBranch,
941
+ templateTag: wizard.templateTag,
552
942
  cloudflareApiToken: wizard.cloudflareApiToken,
553
943
  openaiApiKey: wizard.openaiApiKey ?? "",
554
944
  openclawGatewayToken: wizard.openclawGatewayToken ?? "",
@@ -558,11 +948,8 @@ export async function bootstrap() {
558
948
  // ── Warn about CLI tools that will be installed ─────────────────────
559
949
  const toolDescriptions = [
560
950
  ["git", "Version control (repo operations)"],
561
- ["jq", "JSON processor (API responses)"],
562
- ["glab", "GitLab CLI (repo & auth management)"],
563
951
  ["kubectl", "Kubernetes CLI (cluster management)"],
564
952
  ["helm", "Kubernetes package manager (chart installs)"],
565
- ["k9s", "Terminal UI for Kubernetes (monitoring)"],
566
953
  ["flux-operator", "FluxCD Operator CLI (GitOps reconciliation)"],
567
954
  ["sops", "Mozilla SOPS (secret encryption)"],
568
955
  ["age", "Age encryption (SOPS key backend)"],
@@ -589,12 +976,12 @@ export async function bootstrap() {
589
976
  pc.dim("─".repeat(60)) + "\n\n" +
590
977
  pc.bold("Why are these needed?\n") +
591
978
  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") +
979
+ pc.dim(`deploy components via Helm/Flux, encrypt secrets, and interact with ${providerLabel(wizard.gitProvider)}.\n\n`) +
593
980
  pc.bold("How to uninstall later:\n") +
594
981
  (isMacOS()
595
982
  ? ` ${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`) +
983
+ : ` ${pc.cyan("sudo rm -f /usr/local/bin/{kubectl,helm,flux-operator,sops,age,age-keygen}")}\n` +
984
+ ` ${pc.cyan(`sudo apt remove -y git`)} ${pc.dim("(if installed via apt)")}\n`) +
598
985
  pc.dim("\nAlready-installed tools will be skipped. No system tools will be modified."), "Required CLI Tools");
599
986
  const confirmMsg = toBeInstalled.length > 0
600
987
  ? `Install ${toBeInstalled.length} missing tool(s) and continue?`
@@ -625,18 +1012,20 @@ export async function bootstrap() {
625
1012
  }
626
1013
  }
627
1014
  else {
628
- repoRoot = resolveRepoRoot();
1015
+ repoRoot = resolve(wizard.repoLocalPath || ".");
629
1016
  }
630
1017
  // ── Build final config ───────────────────────────────────────────────
631
1018
  const selectedComponents = wizard.selectedComponents;
632
1019
  const isOpenclawEnabled = openclawEnabled(wizard);
633
1020
  const fullConfig = {
1021
+ gitProvider: wizard.gitProvider,
634
1022
  clusterName: wizard.clusterName,
635
1023
  clusterDomain: wizard.clusterDomain,
636
1024
  clusterPublicIp: wizard.clusterPublicIp,
637
1025
  letsencryptEmail: wizard.letsencryptEmail,
638
1026
  ingressAllowedIps: wizard.ingressAllowedIps,
639
- gitlabPat: wizard.gitlabPat,
1027
+ gitToken: wizard.gitToken,
1028
+ gitFluxToken: wizard.gitFluxToken || undefined,
640
1029
  repoName: wizard.repoName,
641
1030
  repoOwner: wizard.repoOwner,
642
1031
  repoBranch: wizard.repoBranch,
@@ -677,7 +1066,8 @@ export async function bootstrap() {
677
1066
  p.note(`${pc.dim("Since automatic DNS is disabled, add these to")} ${pc.bold("/etc/hosts")}${pc.dim(":")}\n\n` +
678
1067
  hostsEntries.map((e) => pc.cyan(e)).join("\n"), "Local DNS");
679
1068
  const addHosts = await p.confirm({
680
- message: pc.bold("Append these entries to /etc/hosts now?"),
1069
+ message: pc.bold("Append these entries to /etc/hosts now?") +
1070
+ pc.dim(" (requires sudo — macOS will prompt for your password)"),
681
1071
  initialValue: false,
682
1072
  });
683
1073
  if (!p.isCancel(addHosts) && addHosts) {
@@ -708,8 +1098,22 @@ export async function bootstrap() {
708
1098
  summary("Bootstrap Complete", summaryEntries);
709
1099
  const finalSteps = [
710
1100
  `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")}`,
1101
+ `Check status: ${pc.cyan("kubectl get helmreleases -A")}`,
712
1102
  ];
1103
+ if (!commandExists("k9s")) {
1104
+ finalSteps.push(`Install ${pc.bold("k9s")} for a terminal UI to monitor your cluster: ${isMacOS()
1105
+ ? pc.cyan("brew install derailed/k9s/k9s")
1106
+ : pc.cyan("https://k9scli.io/topics/install/")}`);
1107
+ }
1108
+ else {
1109
+ finalSteps.push(`Monitor your cluster: ${pc.cyan("k9s -A")}`);
1110
+ }
1111
+ if (selectedComponents.includes("grafana-operator")) {
1112
+ finalSteps.push(`Grafana dashboard: ${pc.cyan(`https://grafana.${fullConfig.clusterDomain}`)}`);
1113
+ }
1114
+ if (selectedComponents.includes("victoria-metrics-k8s-stack")) {
1115
+ finalSteps.push(`Victoria Metrics: ${pc.cyan(`https://victoria.${fullConfig.clusterDomain}`)}`);
1116
+ }
713
1117
  if (isOpenclawEnabled) {
714
1118
  finalSteps.push(`Open OpenClaw at ${pc.cyan(`https://openclaw.${fullConfig.clusterDomain}`)}`, `Pair a device: ${pc.cyan("npx fluxcd-ai-bootstraper openclaw-pair")}`);
715
1119
  }