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 +59 -28
- package/dist/commands/bootstrap.js +195 -69
- package/dist/commands/bootstrap.js.map +1 -1
- package/dist/commands/template-sync-wizard.d.ts +1 -0
- package/dist/commands/template-sync-wizard.js +169 -0
- package/dist/commands/template-sync-wizard.js.map +1 -0
- package/dist/commands/template-sync.d.ts +8 -0
- package/dist/commands/template-sync.js +41 -0
- package/dist/commands/template-sync.js.map +1 -0
- package/dist/core/bootstrap-runner.d.ts +8 -1
- package/dist/core/bootstrap-runner.js +51 -8
- package/dist/core/bootstrap-runner.js.map +1 -1
- package/dist/core/cloudflare-oauth.js +6 -2
- package/dist/core/cloudflare-oauth.js.map +1 -1
- package/dist/core/encryption.js +1 -1
- package/dist/core/encryption.js.map +1 -1
- package/dist/core/github-oauth.js +4 -2
- package/dist/core/github-oauth.js.map +1 -1
- package/dist/core/gitlab-oauth.js +6 -2
- package/dist/core/gitlab-oauth.js.map +1 -1
- package/dist/core/kubernetes.js +20 -5
- package/dist/core/kubernetes.js.map +1 -1
- package/dist/core/template-sync.d.ts +46 -0
- package/dist/core/template-sync.js +255 -0
- package/dist/core/template-sync.js.map +1 -0
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +12 -2
- package/dist/schemas.js +12 -2
- package/dist/schemas.js.map +1 -1
- package/package.json +32 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# GitOps AI - Bootstrapper
|
|
2
2
|
|
|
3
|
+
[](https://gitops-ai.vercel.app) [](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
|
-
|
|
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
|
|
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
|
|
39
|
-
|
|
40
|
-
| **CPU**
|
|
41
|
-
| **Memory**
|
|
42
|
-
| **Disk**
|
|
43
|
-
| **OS**
|
|
44
|
-
| **Node.js**
|
|
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://
|
|
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
|
|
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
|
|
132
|
-
|
|
133
|
-
| [Prerequisites](docs/prerequisites.md)
|
|
134
|
-
| [Bootstrap](docs/bootstrap.md)
|
|
135
|
-
| [Architecture](docs/architecture.md)
|
|
136
|
-
| [Configuration](docs/configuration.md)
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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: "
|
|
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
|
|
669
|
-
message: pc.bold("
|
|
670
|
-
|
|
671
|
-
|
|
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(
|
|
677
|
+
if (p.isCancel(mode))
|
|
674
678
|
return back();
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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(
|
|
790
|
+
if (p.isCancel(allowedIps))
|
|
699
791
|
return back();
|
|
700
|
-
return { ...state, clusterPublicIp:
|
|
792
|
+
return { ...state, clusterPublicIp: localIp, ingressAllowedIps: allowedIps };
|
|
701
793
|
},
|
|
702
|
-
review: (state) => ["
|
|
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://${
|
|
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://${
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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 =
|
|
884
|
-
if (!
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1197
|
+
initialValue: true,
|
|
1072
1198
|
});
|
|
1073
1199
|
if (!p.isCancel(addHosts) && addHosts) {
|
|
1074
1200
|
try {
|