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.
- package/LICENSE +21 -0
- package/README.md +74 -41
- package/dist/commands/bootstrap.js +641 -117
- 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.js +28 -11
- package/dist/core/bootstrap-runner.js.map +1 -1
- package/dist/core/cloudflare-oauth.d.ts +1 -0
- package/dist/core/cloudflare-oauth.js +311 -0
- package/dist/core/cloudflare-oauth.js.map +1 -0
- package/dist/core/dependencies.js +0 -12
- package/dist/core/dependencies.js.map +1 -1
- package/dist/core/encryption.js +1 -1
- package/dist/core/encryption.js.map +1 -1
- package/dist/core/flux.d.ts +1 -1
- package/dist/core/flux.js +57 -8
- package/dist/core/flux.js.map +1 -1
- package/dist/core/git-provider.d.ts +38 -0
- package/dist/core/git-provider.js +30 -0
- package/dist/core/git-provider.js.map +1 -0
- package/dist/core/github-oauth.d.ts +1 -0
- package/dist/core/github-oauth.js +110 -0
- package/dist/core/github-oauth.js.map +1 -0
- package/dist/core/github.d.ts +12 -0
- package/dist/core/github.js +188 -0
- package/dist/core/github.js.map +1 -0
- package/dist/core/gitlab-oauth.d.ts +1 -0
- package/dist/core/gitlab-oauth.js +194 -0
- package/dist/core/gitlab-oauth.js.map +1 -0
- package/dist/core/gitlab.d.ts +4 -9
- package/dist/core/gitlab.js +127 -56
- package/dist/core/gitlab.js.map +1 -1
- package/dist/core/kubernetes.d.ts +9 -0
- package/dist/core/kubernetes.js +51 -1
- package/dist/core/kubernetes.js.map +1 -1
- package/dist/core/template-sync.d.ts +46 -0
- package/dist/core/template-sync.js +249 -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 +17 -4
- package/dist/schemas.js +17 -3
- package/dist/schemas.js.map +1 -1
- 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
|
|
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
|
-
// ──
|
|
103
|
+
// ── Git Provider ────────────────────────────────────────────────────
|
|
67
104
|
{
|
|
68
|
-
id: "
|
|
69
|
-
section: "
|
|
70
|
-
skip: (state) =>
|
|
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(
|
|
74
|
-
validate: (v) => {
|
|
75
|
-
|
|
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
|
|
183
|
+
return await enrichWithUser(state, v, provider);
|
|
82
184
|
},
|
|
83
|
-
review: (state) => ["
|
|
185
|
+
review: (state) => ["Token", maskSecret(state.gitToken)],
|
|
84
186
|
},
|
|
85
187
|
{
|
|
86
188
|
id: "repoOwner",
|
|
87
|
-
section: "
|
|
88
|
-
skip: (state) =>
|
|
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(
|
|
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: "
|
|
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:
|
|
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: "
|
|
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("
|
|
131
|
-
placeholder: `./${state.repoName} (relative
|
|
132
|
-
defaultValue: state.repoLocalPath ||
|
|
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) =>
|
|
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: "
|
|
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:
|
|
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:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
301
|
-
validate: (v) => {
|
|
302
|
-
|
|
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: "
|
|
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
|
|
324
|
-
message: pc.bold("
|
|
325
|
-
|
|
326
|
-
|
|
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(
|
|
677
|
+
if (p.isCancel(mode))
|
|
329
678
|
return back();
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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(
|
|
790
|
+
if (p.isCancel(allowedIps))
|
|
354
791
|
return back();
|
|
355
|
-
return { ...state, clusterPublicIp:
|
|
792
|
+
return { ...state, clusterPublicIp: localIp, ingressAllowedIps: allowedIps };
|
|
356
793
|
},
|
|
357
|
-
review: (state) => ["
|
|
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
|
-
|
|
373
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
597
|
-
` ${pc.cyan(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")}
|
|
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
|
}
|