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