gitops-ai 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # GitOps AI - Bootstrapper
2
2
 
3
+ [![Website](https://img.shields.io/badge/website-gitops--ai.vercel.app-blue)](https://gitops-ai.vercel.app) [![Docs](https://img.shields.io/badge/docs-gitops--ai.vercel.app-orange)](https://gitops-ai.vercel.app/#docs/prerequisites)
4
+
3
5
  GitOps-managed Kubernetes infrastructure for AI-powered applications powered by the [Flux Operator](https://fluxoperator.dev/) and [Flux CD](https://fluxcd.io/). A single bootstrap application provisions a Kubernetes cluster, installs all infrastructure components, and enables continuous delivery from Git.
4
6
 
5
7
  ## Why GitOps for your infrastructure
@@ -14,7 +16,8 @@ GitOps-managed Kubernetes infrastructure for AI-powered applications powered by
14
16
 
15
17
  ## Quick Start
16
18
 
17
- Run on our macOS machine:
19
+ On your Mac or Linux machine:
20
+
18
21
  ```bash
19
22
  npx gitops-ai bootstrap
20
23
  ```
@@ -31,17 +34,17 @@ Or, if you already have Node.js >= 18:
31
34
  npx gitops-ai bootstrap
32
35
  ```
33
36
 
34
- The interactive wizard will prompt for your GitLab PAT, fork the template into your namespace, and run the full bootstrap.
37
+ The interactive wizard will prompt for your Git provider (GitHub or GitLab), create or use a repository from the [GitOps AI Template](https://github.com/GitOpsAI/gitops-ai-template), and run the full bootstrap.
35
38
 
36
39
  ## Requirements
37
40
 
38
- | Resource | Minimum |
39
- |----------------|------------------------|
40
- | **CPU** | 2+ cores |
41
- | **Memory** | 4+ GB |
42
- | **Disk** | 20+ GB free |
43
- | **OS** | Ubuntu 25.04+ or macOS |
44
- | **Node.js** | 18+ (installed automatically by `install.sh`) |
41
+ | Resource | Minimum |
42
+ |-------------|-----------------------------------------------|
43
+ | **CPU** | 2+ cores |
44
+ | **Memory** | 4+ GB |
45
+ | **Disk** | 20+ GB free |
46
+ | **OS** | Ubuntu 25.04+ or macOS |
47
+ | **Node.js** | 18+ (installed automatically by `install.sh`) |
45
48
 
46
49
  You will also need a [GitLab PAT](docs/prerequisites.md#1-gitlab-personal-access-token), a [Cloudflare API Token](docs/prerequisites.md#2-cloudflare-api-token) (if using automatic DNS/TLS), and an [OpenAI API Key](docs/prerequisites.md#3-openai-api-key) (if using OpenClaw). See [Prerequisites](docs/prerequisites.md) for full details.
47
50
 
@@ -57,7 +60,7 @@ On Linux the bootstrap installs k3s directly -- no Docker required.
57
60
 
58
61
  ## Template Repository
59
62
 
60
- This CLI bootstraps clusters from the [GitOps AI Template](https://gitlab.com/everythings-gonna-be-alright/gitops_ai_template) -- a ready-made GitOps repository structure that Flux uses as the single source of truth for your cluster.
63
+ This CLI bootstraps clusters from the [GitOps AI Template](https://github.com/GitOpsAI/gitops-ai-template) -- a ready-made GitOps repository structure that Flux uses as the single source of truth for your cluster.
61
64
 
62
65
  The template contains the declarative Kubernetes manifests, HelmRelease definitions, Kustomization overlays, and SOPS encryption configuration that define a complete infrastructure stack. When you run `npx gitops-ai bootstrap`, the CLI forks this template into your GitLab namespace, customises it with your cluster variables (domain, tokens, component selections), and points Flux at the resulting repository. From that moment on, every `git push` to the repo triggers Flux reconciliation -- your cluster converges to match whatever is declared in Git.
63
66
 
@@ -67,9 +70,21 @@ Keeping the template in a separate repository means:
67
70
  - **Clean separation** -- the bootstrapper CLI handles provisioning logic; the template holds pure infrastructure declarations. Each can be versioned and tested independently.
68
71
  - **Customisation without lock-in** -- after the fork you own the repo. Add namespaces, swap Helm charts, or restructure directories to fit your needs.
69
72
 
73
+ ### Repository layout (template → your repo)
74
+
75
+ The upstream template (and your bootstrapped repo) is organised roughly as:
76
+
77
+ | Path | Role |
78
+ |--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
79
+ | `templates/<category>/…` | Shared Helm bases and component manifests (e.g. `templates/system/`, `templates/ai/`, `templates/monitoring/`). |
80
+ | `clusters/_template/` | Prototype cluster layout; the CLI copies this to `clusters/<your-cluster-name>/` during bootstrap. |
81
+ | `clusters/<name>/` | Your live cluster overlay (`cluster-sync.yaml`, `components/`, encrypted secrets). |
82
+
83
+ See [Architecture](docs/architecture.md) for diagrams and a fuller tree.
84
+
70
85
  ## CLI Commands
71
86
 
72
- The CLI provides three commands:
87
+ The CLI provides these commands:
73
88
 
74
89
  ### `bootstrap` (alias: `install`)
75
90
 
@@ -89,16 +104,16 @@ SOPS secret encryption management. Run without arguments for an interactive menu
89
104
  npx gitops-ai sops [subcommand] [file]
90
105
  ```
91
106
 
92
- | Subcommand | Description |
93
- |------------------|----------------------------------------------------------|
107
+ | Subcommand | Description |
108
+ |------------------|------------------------------------------------------------------------|
94
109
  | `init` | First-time setup: generate age key, create `.sops.yaml` and K8s secret |
95
- | `encrypt` | Encrypt all unencrypted secret files |
96
- | `encrypt <file>` | Encrypt a specific file |
97
- | `decrypt <file>` | Decrypt a file for viewing (re-encrypt before commit) |
98
- | `edit <file>` | Open encrypted file in `$EDITOR` (auto re-encrypts on save) |
99
- | `status` | Show encryption status of all secret files |
100
- | `import` | Import an existing age key into a new cluster |
101
- | `rotate` | Rotate to a new age key and re-encrypt everything |
110
+ | `encrypt` | Encrypt all unencrypted secret files |
111
+ | `encrypt <file>` | Encrypt a specific file |
112
+ | `decrypt <file>` | Decrypt a file for viewing (re-encrypt before commit) |
113
+ | `edit <file>` | Open encrypted file in `$EDITOR` (auto re-encrypts on save) |
114
+ | `status` | Show encryption status of all secret files |
115
+ | `import` | Import an existing age key into a new cluster |
116
+ | `rotate` | Rotate to a new age key and re-encrypt everything |
102
117
 
103
118
  ### `openclaw-pair`
104
119
 
@@ -108,6 +123,18 @@ Pair an OpenClaw device with the cluster after bootstrap:
108
123
  npx gitops-ai openclaw-pair
109
124
  ```
110
125
 
126
+ ### `template sync`
127
+
128
+ Fetch the upstream GitOps template and merge changes into your current branch. Run without flags for an **interactive wizard** (tag picker, diff preview with risk classification, merge/dry-run/cancel), or pass flags for non-interactive use:
129
+
130
+ ```bash
131
+ npx gitops-ai template sync # interactive wizard
132
+ npx gitops-ai template sync --ref v1.0.0 # non-interactive merge
133
+ npx gitops-ai template sync --ref main --dry-run # non-interactive preview
134
+ ```
135
+
136
+ See [Template synchronization](docs/template-sync.md).
137
+
111
138
  ## Components
112
139
 
113
140
  The bootstrap wizard lets you select which components to install:
@@ -128,23 +155,27 @@ Components marked **DNS/TLS** are automatically enabled when you opt into automa
128
155
 
129
156
  ## Documentation
130
157
 
131
- | Document | Description |
132
- |----------|-------------|
133
- | [Prerequisites](docs/prerequisites.md) | API tokens, Docker runtime, network requirements |
134
- | [Bootstrap](docs/bootstrap.md) | What the bootstrap does, wizard walkthrough, resume capability |
135
- | [Architecture](docs/architecture.md) | Repository structure, Flux Operator, GitOps workflow |
136
- | [Configuration](docs/configuration.md) | Cluster variables, environment variables, post-bootstrap changes |
158
+ | Document | Description |
159
+ |---------------------------------------------------|---------------------------------------------------------------------|
160
+ | [Prerequisites](docs/prerequisites.md) | Node.js, Docker (macOS), Git provider, optional Cloudflare / OpenAI |
161
+ | [Bootstrap](docs/bootstrap.md) | What the bootstrap does, wizard walkthrough, resume capability |
162
+ | [Architecture](docs/architecture.md) | Repositories, bootstrap flow, Flux Operator & Instance, repo tree |
163
+ | [Configuration](docs/configuration.md) | Cluster variables, SOPS defaults, post-bootstrap changes |
164
+ | [Template synchronization](docs/template-sync.md) | Upstream merges, `template sync`, CI parity, risk tiers |
165
+ | [Scaling](docs/scaling.md) | Adding k3s worker and server nodes (Linux) |
166
+ | [Security](docs/security.md) | SOPS, Git auth, hardening, network |
137
167
 
138
168
  ## Development
139
169
 
140
170
  ```bash
141
- git clone <repo-url> && cd gitops-ai
171
+ git clone https://gitlab.com/everythings-gonna-be-alright/gitops_ai_bootstrapper.git
172
+ cd gitops_ai_bootstrapper
142
173
  npm install
143
174
 
144
175
  npm run dev # Run CLI locally via tsx
145
176
  npm run build # Compile TypeScript to dist/
146
177
  npm run typecheck # Type-check without emitting
147
- npm run test:validate # Validate Flux build against template
178
+ npm run test:sync # Unit tests for template sync logic
148
179
  npm run test:integration # Full k3d + Flux integration test (requires Docker)
149
180
  ```
150
181
 
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { execSync } from "node:child_process";
3
+ import { networkInterfaces } from "node:os";
3
4
  import { resolve } from "node:path";
4
5
  import * as p from "@clack/prompts";
5
6
  import pc from "picocolors";
@@ -8,11 +9,12 @@ import { saveInstallPlan, loadInstallPlan, clearInstallPlan } from "../utils/con
8
9
  import { execAsync, exec, execSafe, commandExists } from "../utils/shell.js";
9
10
  import { isMacOS, isCI } from "../utils/platform.js";
10
11
  import { ensureAll } from "../core/dependencies.js";
11
- import { runBootstrap } from "../core/bootstrap-runner.js";
12
+ import { runBootstrap, stripTemplateGitHubDirectory } from "../core/bootstrap-runner.js";
12
13
  import * as k8s from "../core/kubernetes.js";
13
14
  import * as flux from "../core/flux.js";
14
15
  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";
16
+ import { COMPONENTS, REQUIRED_COMPONENT_IDS, DNS_TLS_COMPONENT_IDS, MONITORING_COMPONENT_IDS, OPTIONAL_COMPONENTS, SOURCE_TEMPLATE_HOST, SOURCE_PROJECT_PATH, } from "../schemas.js";
17
+ import { fetchTemplateTags, readPackageVersion } from "../core/template-sync.js";
16
18
  import { stepWizard, back, maskSecret, } from "../utils/wizard.js";
17
19
  import { loginAndCreateCloudflareToken } from "../core/cloudflare-oauth.js";
18
20
  // ---------------------------------------------------------------------------
@@ -40,20 +42,7 @@ function openclawEnabled(state) {
40
42
  function componentLabel(id) {
41
43
  return COMPONENTS.find((c) => c.id === id)?.label ?? id;
42
44
  }
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
- }
45
+ // fetchTemplateTags is imported from core/template-sync.ts
57
46
  function providerLabel(type) {
58
47
  return type === "gitlab" ? "GitLab" : "GitHub";
59
48
  }
@@ -447,6 +436,8 @@ function buildFields(detectedIp, hasSavedPlan) {
447
436
  hint: "Victoria Metrics + Grafana Operator (dashboards, alerting, metrics)",
448
437
  };
449
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));
450
441
  const selected = await p.multiselect({
451
442
  message: pc.bold("Optional components to install"),
452
443
  options: [
@@ -458,7 +449,7 @@ function buildFields(detectedIp, hasSavedPlan) {
458
449
  })),
459
450
  ],
460
451
  initialValues: [
461
- ...(hasMonitoring ? [MONITORING_GROUP_ID] : []),
452
+ ...((hasMonitoring || !monitoringExplicitlyRemoved) ? [MONITORING_GROUP_ID] : []),
462
453
  ...state.selectedComponents.filter((id) => OPTIONAL_COMPONENTS.some((c) => c.id === id)),
463
454
  ],
464
455
  required: false,
@@ -640,7 +631,10 @@ function buildFields(detectedIp, hasSavedPlan) {
640
631
  ` 3. Name it (e.g. ${pc.cyan("gitops-ai")})\n` +
641
632
  ` 4. Copy the key value\n\n` +
642
633
  pc.dim("The key starts with sk-… and is only shown once."), "OpenAI API Key");
643
- p.log.info(pc.dim("Opening browser…"));
634
+ await p.text({
635
+ message: pc.dim("Press ") + pc.bold(pc.yellow("Enter")) + pc.dim(" to open browser…"),
636
+ defaultValue: "",
637
+ });
644
638
  openUrl(OPENAI_API_KEYS_URL);
645
639
  }
646
640
  const v = await p.password({
@@ -661,45 +655,143 @@ function buildFields(detectedIp, hasSavedPlan) {
661
655
  },
662
656
  // ── Network ──────────────────────────────────────────────────────────
663
657
  {
664
- id: "ingressAllowedIps",
658
+ id: "networkAccessMode",
665
659
  section: "Network",
666
- skip: (state) => saved(state, "ingressAllowedIps"),
660
+ skip: (state) => saved(state, "ingressAllowedIps") && saved(state, "clusterPublicIp"),
667
661
  run: async (state) => {
668
- const v = await p.text({
669
- message: pc.bold("IPs allowed to access your cluster (CIDR, comma-separated)"),
670
- placeholder: "0.0.0.0/0",
671
- 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
+ ],
672
676
  });
673
- if (p.isCancel(v))
677
+ if (p.isCancel(mode))
674
678
  return back();
675
- return { ...state, ingressAllowedIps: v };
676
- },
677
- review: (state) => ["Allowed IPs", state.ingressAllowedIps],
678
- },
679
- {
680
- id: "clusterPublicIp",
681
- section: "Network",
682
- skip: (state) => dnsAndTlsEnabled(state) && saved(state, "clusterPublicIp"),
683
- run: async (state) => {
684
- const useLocal = !dnsAndTlsEnabled(state);
685
- const fallback = useLocal ? "127.0.0.1" : detectedIp;
686
- const defaultIp = useLocal ? fallback : (state.clusterPublicIp || fallback);
687
- const v = await p.text({
688
- message: useLocal
689
- ? pc.bold("Cluster IP") + pc.dim(" (local because DNS management is disabled. Rewrite if it necessary)")
690
- : pc.bold("Public IP of your cluster"),
691
- defaultValue: defaultIp,
692
- placeholder: fallback || "x.x.x.x",
693
- validate: (v) => {
694
- if (!v && !defaultIp)
695
- return "Required";
696
- },
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",
697
789
  });
698
- if (p.isCancel(v))
790
+ if (p.isCancel(allowedIps))
699
791
  return back();
700
- return { ...state, clusterPublicIp: v };
792
+ return { ...state, clusterPublicIp: localIp, ingressAllowedIps: allowedIps };
701
793
  },
702
- review: (state) => ["Public IP", state.clusterPublicIp],
794
+ review: (state) => ["Network", `${state.clusterPublicIp} allowed: ${state.ingressAllowedIps}`],
703
795
  },
704
796
  ];
705
797
  }
@@ -744,6 +836,8 @@ async function createAndCloneRepo(wizard) {
744
836
  log.success(`Created: ${pathWithNs}`);
745
837
  }
746
838
  const cloneDir = wizard.repoLocalPath || wizard.repoName;
839
+ /** Set when we `git clone` the template; full clone includes `.github` — strip before push (OAuth lacks `workflow` scope). */
840
+ let clonedTemplateFromUpstream = false;
747
841
  if (existsSync(cloneDir)) {
748
842
  log.warn(`Directory './${cloneDir}' already exists locally`);
749
843
  const useDir = await p.confirm({
@@ -762,15 +856,16 @@ async function createAndCloneRepo(wizard) {
762
856
  }
763
857
  }
764
858
  else {
859
+ clonedTemplateFromUpstream = true;
765
860
  const cloneRef = wizard.templateTag || "main";
766
861
  let clonedRef = cloneRef;
767
862
  try {
768
- await withSpinner(`Cloning template (${cloneRef})`, () => execAsync(`git clone --quiet --branch "${cloneRef}" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
863
+ await withSpinner(`Cloning template (${cloneRef})`, () => execAsync(`git clone --quiet --branch "${cloneRef}" "https://${SOURCE_TEMPLATE_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
769
864
  }
770
865
  catch {
771
866
  log.warn(`Tag/branch '${cloneRef}' not found — falling back to 'main'`);
772
867
  clonedRef = "main";
773
- await withSpinner("Cloning template (main)", () => execAsync(`git clone --quiet --branch "main" "https://${SOURCE_GITLAB_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
868
+ await withSpinner("Cloning template (main)", () => execAsync(`git clone --quiet --branch "main" "https://${SOURCE_TEMPLATE_HOST}/${SOURCE_PROJECT_PATH}.git" "${cloneDir}"`));
774
869
  }
775
870
  if (clonedRef !== wizard.repoBranch) {
776
871
  exec(`git checkout -B "${wizard.repoBranch}"`, { cwd: cloneDir });
@@ -778,7 +873,10 @@ async function createAndCloneRepo(wizard) {
778
873
  exec(`git remote set-url origin "${httpUrl}"`, { cwd: cloneDir });
779
874
  }
780
875
  const authRemote = provider.getAuthRemoteUrl(host, pathWithNs, wizard.gitToken);
781
- await withSpinner(`Pushing to ${pathWithNs}`, () => {
876
+ await withSpinner(`Pushing to ${pathWithNs}`, async () => {
877
+ if (clonedTemplateFromUpstream) {
878
+ await stripTemplateGitHubDirectory(cloneDir);
879
+ }
782
880
  const forceFlag = repoExisted ? " --force" : "";
783
881
  return execAsync(`git push -u "${authRemote}" "${wizard.repoBranch}"${forceFlag} --quiet`, { cwd: cloneDir });
784
882
  });
@@ -837,10 +935,26 @@ export async function bootstrap() {
837
935
  }
838
936
  });
839
937
  console.log();
840
- p.box(`💅 Secure, isolated and flexible GitOps infrastructure for modern requirements\n` +
841
- `🤖 You can manage it yourself — or delegate to AI.\n` +
842
- `🔐 Encrypted secrets, hardened containers, continuous delivery.`, pc.bold("Welcome to GitOps AI Bootstrapper"), {
843
- contentAlign: "center",
938
+ const version = readPackageVersion();
939
+ const logo = [
940
+ " ▄█████▄ ",
941
+ " ██ ◆ ██ ",
942
+ " ██ ██ ",
943
+ " ▀██ ██▀ ",
944
+ " ▀█▀ ",
945
+ ];
946
+ const taglines = [
947
+ "💅 Secure, isolated & flexible GitOps infrastructure",
948
+ "🤖 Manage it yourself — or delegate to AI",
949
+ "🔐 Encrypted secrets, hardened containers,",
950
+ " continuous delivery",
951
+ pc.dim(`v${version}`),
952
+ ];
953
+ const banner = logo
954
+ .map((l, i) => pc.cyan(l) + " " + (taglines[i] ?? ""))
955
+ .join("\n");
956
+ p.box(banner, pc.bold("Welcome to GitOps AI Bootstrapper"), {
957
+ contentAlign: "left",
844
958
  titleAlign: "center",
845
959
  rounded: true,
846
960
  formatBorder: (text) => pc.cyan(text),
@@ -879,14 +993,18 @@ export async function bootstrap() {
879
993
  return;
880
994
  }
881
995
  }
882
- // ── Detect public IP (silent) ────────────────────────────────────────
883
- let detectedIp = prev.clusterPublicIp ?? "";
884
- if (!detectedIp) {
885
- try {
886
- detectedIp = await execAsync("curl -s --max-time 5 ifconfig.me");
887
- }
888
- catch {
889
- detectedIp = "";
996
+ // ── Detect public IP (silent, try multiple services) ─────────────────
997
+ let detectedIp = "";
998
+ if (!prev.clusterPublicIp) {
999
+ for (const svc of ["ifconfig.me", "api.ipify.org", "icanhazip.com"]) {
1000
+ try {
1001
+ const ip = (await execAsync(`curl -s --max-time 4 ${svc}`)).trim();
1002
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
1003
+ detectedIp = ip;
1004
+ break;
1005
+ }
1006
+ }
1007
+ catch { /* try next */ }
890
1008
  }
891
1009
  }
892
1010
  // ── Run interactive wizard ───────────────────────────────────────────
@@ -898,6 +1016,7 @@ export async function bootstrap() {
898
1016
  : [
899
1017
  ...REQUIRED_COMPONENT_IDS,
900
1018
  ...(savedDnsTls ? DNS_TLS_COMPONENT_IDS : []),
1019
+ ...MONITORING_COMPONENT_IDS,
901
1020
  ...OPTIONAL_COMPONENTS.map((c) => c.id),
902
1021
  ];
903
1022
  const initialState = {
@@ -1035,6 +1154,8 @@ export async function bootstrap() {
1035
1154
  ? wizard.openclawGatewayToken
1036
1155
  : undefined,
1037
1156
  selectedComponents,
1157
+ templateRef: wizard.templateTag?.trim() ||
1158
+ (isNewRepo(wizard) ? "main" : undefined),
1038
1159
  };
1039
1160
  // ── Check macOS prerequisites ────────────────────────────────────────
1040
1161
  if (isMacOS()) {
@@ -1055,20 +1176,25 @@ export async function bootstrap() {
1055
1176
  log.error(`Bootstrap failed\n${formatError(err)}`);
1056
1177
  return process.exit(1);
1057
1178
  }
1058
- // ── /etc/hosts suggestion (no DNS management) ───────────────────────
1059
- if (!wizard.manageDnsAndTls) {
1179
+ // ── /etc/hosts suggestion (local IP or no DNS management) ───────────
1180
+ const isLocalIp = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(fullConfig.clusterPublicIp)
1181
+ || fullConfig.clusterPublicIp === "localhost";
1182
+ if (isLocalIp || !wizard.manageDnsAndTls) {
1060
1183
  const hostsEntries = selectedComponents
1061
1184
  .map((id) => COMPONENTS.find((c) => c.id === id))
1062
1185
  .filter((c) => !!c?.subdomain)
1063
1186
  .map((c) => `${fullConfig.clusterPublicIp} ${c.subdomain}.${fullConfig.clusterDomain}`);
1064
1187
  if (hostsEntries.length > 0) {
1065
1188
  const hostsBlock = hostsEntries.join("\n");
1066
- p.note(`${pc.dim("Since automatic DNS is disabled, add these to")} ${pc.bold("/etc/hosts")}${pc.dim(":")}\n\n` +
1189
+ const reason = isLocalIp
1190
+ ? "Your cluster uses a local/private IP, so DNS won't resolve publicly."
1191
+ : "Automatic DNS is disabled.";
1192
+ p.note(`${pc.dim(reason + " Add these to")} ${pc.bold("/etc/hosts")}${pc.dim(":")}\n\n` +
1067
1193
  hostsEntries.map((e) => pc.cyan(e)).join("\n"), "Local DNS");
1068
1194
  const addHosts = await p.confirm({
1069
1195
  message: pc.bold("Append these entries to /etc/hosts now?") +
1070
1196
  pc.dim(" (requires sudo — macOS will prompt for your password)"),
1071
- initialValue: false,
1197
+ initialValue: true,
1072
1198
  });
1073
1199
  if (!p.isCancel(addHosts) && addHosts) {
1074
1200
  try {