gitops-ai 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,48 +16,33 @@ GitOps-managed Kubernetes infrastructure for AI-powered applications powered by
16
16
 
17
17
  ## Quick Start
18
18
 
19
- On your Mac or Linux machine:
19
+ If you already have **Node.js** on your Mac or Linux machine:
20
20
 
21
21
  ```bash
22
22
  npx gitops-ai bootstrap
23
23
  ```
24
24
 
25
- Or SSH into your server (or run locally on macOS) and run:
25
+ If Node is not installed yet, use the helper script — it installs Node when missing (on macOS, [Homebrew](https://brew.sh) is required for that step), then starts the wizard:
26
26
 
27
27
  ```bash
28
- curl -sfL https://raw.githubusercontent.com/your-org/gitops-ai/main/install.sh | bash
29
- ```
30
-
31
- Or, if you already have Node.js >= 18:
32
-
33
- ```bash
34
- npx gitops-ai bootstrap
28
+ curl -sfL https://raw.githubusercontent.com/GitOpsAI/gitops-ai-bootstrapper/refs/heads/main/install.sh | bash
35
29
  ```
36
30
 
37
31
  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.
38
32
 
39
33
  ## Requirements
40
34
 
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`) |
35
+ | Resource | Minimum |
36
+ |-------------|----------------------------------------------------------|
37
+ | **CPU** | 2+ cores |
38
+ | **Memory** | 4+ GB |
39
+ | **Disk** | 20+ GB free |
40
+ | **OS** | Ubuntu 25.04+ or macOS |
41
+ | **Node.js** | 18+ (installed automatically by `install.sh`) |
42
+ | **Docker** | macOS only — Docker Desktop; not used on Linux bootstrap |
48
43
 
49
44
  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.
50
45
 
51
- ### Docker runtime (macOS only)
52
-
53
- macOS requires a Docker-compatible runtime for k3d. Install one of:
54
-
55
- - [Docker Desktop](https://www.docker.com/products/docker-desktop/)
56
- - [OrbStack](https://orbstack.dev/)
57
- - [Colima](https://github.com/abiosoft/colima)
58
-
59
- On Linux the bootstrap installs k3s directly -- no Docker required.
60
-
61
46
  ## Template Repository
62
47
 
63
48
  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.
@@ -74,11 +59,11 @@ Keeping the template in a separate repository means:
74
59
 
75
60
  The upstream template (and your bootstrapped repo) is organised roughly as:
76
61
 
77
- | Path | Role |
78
- |--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
62
+ | Path | Role |
63
+ |--------------------------|-----------------------------------------------------------------------------------------------------------------|
79
64
  | `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). |
65
+ | `clusters/_template/` | Prototype cluster layout; the CLI copies this to `clusters/<your-cluster-name>/` during bootstrap. |
66
+ | `clusters/<name>/` | Your live cluster overlay (`cluster-sync.yaml`, `components/`, encrypted secrets). |
82
67
 
83
68
  See [Architecture](docs/architecture.md) for diagrams and a fuller tree.
84
69
 
@@ -8,9 +8,10 @@ import { header, log, summary, nextSteps, finish, handleCancel, withSpinner, for
8
8
  import { saveInstallPlan, loadInstallPlan, clearInstallPlan } from "../utils/config.js";
9
9
  import { execAsync, exec, execSafe, commandExists } from "../utils/shell.js";
10
10
  import { isMacOS, isCI } from "../utils/platform.js";
11
- import { ensureAll } from "../core/dependencies.js";
11
+ import { ensureAll, ensureDockerDaemonReady } from "../core/dependencies.js";
12
12
  import { runBootstrap, stripTemplateGitHubDirectory } from "../core/bootstrap-runner.js";
13
13
  import * as k8s from "../core/kubernetes.js";
14
+ import * as k8sApi from "../core/k8s-api.js";
14
15
  import * as flux from "../core/flux.js";
15
16
  import { getProvider, } from "../core/git-provider.js";
16
17
  import { COMPONENTS, REQUIRED_COMPONENT_IDS, DNS_TLS_COMPONENT_IDS, MONITORING_COMPONENT_IDS, OPTIONAL_COMPONENTS, SOURCE_TEMPLATE_HOST, SOURCE_PROJECT_PATH, } from "../schemas.js";
@@ -64,8 +65,11 @@ async function enrichWithUser(state, token, provider) {
64
65
  // ---------------------------------------------------------------------------
65
66
  // Wizard field definitions (Esc / Ctrl+C = go back one field)
66
67
  // ---------------------------------------------------------------------------
67
- function buildFields(detectedIp, hasSavedPlan) {
68
+ function buildFields(detectedIp, hasSavedPlan, savedPlanRaw) {
68
69
  const saved = (state, key) => hasSavedPlan && !!state[key];
70
+ const skipAdditionalSettingsToggle = hasSavedPlan &&
71
+ savedPlanRaw != null &&
72
+ savedPlanRaw.enableAdditionalSettings !== undefined;
69
73
  return [
70
74
  // ── Setup Mode ──────────────────────────────────────────────────────
71
75
  {
@@ -343,64 +347,6 @@ function buildFields(detectedIp, hasSavedPlan) {
343
347
  ? ["Local path", `./${state.repoLocalPath}`]
344
348
  : ["Local repo path", resolve(state.repoLocalPath || ".")],
345
349
  },
346
- {
347
- id: "repoBranch",
348
- section: "Git Repository",
349
- skip: (state) => saved(state, "repoBranch"),
350
- run: async (state) => {
351
- const v = await p.text({
352
- message: pc.bold("Git branch for Flux"),
353
- placeholder: "main",
354
- defaultValue: state.repoBranch,
355
- });
356
- if (p.isCancel(v))
357
- return back();
358
- return { ...state, repoBranch: v };
359
- },
360
- review: (state) => ["Branch", state.repoBranch],
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
- },
404
350
  // ── DNS & TLS ─────────────────────────────────────────────────────────
405
351
  {
406
352
  id: "manageDnsAndTls",
@@ -793,6 +739,87 @@ function buildFields(detectedIp, hasSavedPlan) {
793
739
  },
794
740
  review: (state) => ["Network", `${state.clusterPublicIp} allowed: ${state.ingressAllowedIps}`],
795
741
  },
742
+ // ── Additional settings (last — rarely needed) ─────────────────────────
743
+ {
744
+ id: "enableAdditionalSettings",
745
+ section: "Additional settings",
746
+ skip: () => skipAdditionalSettingsToggle,
747
+ run: async (state) => {
748
+ const v = await p.confirm({
749
+ message: pc.bold("Setup additional settings?") +
750
+ `\n${pc.dim("Regularly not needed")}`,
751
+ initialValue: state.enableAdditionalSettings,
752
+ });
753
+ if (p.isCancel(v))
754
+ return back();
755
+ return { ...state, enableAdditionalSettings: v };
756
+ },
757
+ review: (state) => [
758
+ "Customize branch & template",
759
+ state.enableAdditionalSettings
760
+ ? "Yes"
761
+ : "No — defaults (main / main)",
762
+ ],
763
+ },
764
+ {
765
+ id: "repoBranch",
766
+ section: "Additional settings",
767
+ hidden: (state) => !state.enableAdditionalSettings,
768
+ skip: (state) => saved(state, "repoBranch"),
769
+ run: async (state) => {
770
+ const v = await p.text({
771
+ message: pc.bold("Git branch for Flux"),
772
+ placeholder: "main",
773
+ defaultValue: state.repoBranch,
774
+ });
775
+ if (p.isCancel(v))
776
+ return back();
777
+ return { ...state, repoBranch: v };
778
+ },
779
+ review: (state) => ["Flux Git branch", state.repoBranch],
780
+ },
781
+ {
782
+ id: "templateTag",
783
+ section: "Additional settings",
784
+ hidden: (state) => !isNewRepo(state) || !state.enableAdditionalSettings,
785
+ skip: (state) => saved(state, "templateTag"),
786
+ run: async (state) => {
787
+ const tags = await fetchTemplateTags();
788
+ if (tags.length > 0) {
789
+ const options = [
790
+ ...tags.map((tag, i) => ({
791
+ value: tag,
792
+ label: tag,
793
+ hint: i === 0 ? "latest" : undefined,
794
+ })),
795
+ {
796
+ value: "__manual__",
797
+ label: "Enter manually…",
798
+ hint: "type a tag or branch name",
799
+ },
800
+ ];
801
+ const v = await p.select({
802
+ message: pc.bold("Template version (tag) to clone"),
803
+ options,
804
+ initialValue: state.templateTag || tags[0],
805
+ });
806
+ if (p.isCancel(v))
807
+ return back();
808
+ if (v !== "__manual__") {
809
+ return { ...state, templateTag: v };
810
+ }
811
+ }
812
+ const v = await p.text({
813
+ message: pc.bold("Template tag or branch to clone"),
814
+ placeholder: "main",
815
+ defaultValue: state.templateTag || "main",
816
+ });
817
+ if (p.isCancel(v))
818
+ return back();
819
+ return { ...state, templateTag: v };
820
+ },
821
+ review: (state) => ["Template tag", state.templateTag],
822
+ },
796
823
  ];
797
824
  }
798
825
  // ---------------------------------------------------------------------------
@@ -902,7 +929,12 @@ export async function openclawPair() {
902
929
  handleCancel();
903
930
  log.step("Listing pending device requests");
904
931
  try {
905
- await execAsync("kubectl exec -n openclaw deployment/openclaw -c main -- node dist/index.js devices list");
932
+ const kc = k8sApi.kubeConfigFromDefault();
933
+ const code = await k8sApi.execInDeploymentContainerTty(kc, "openclaw", "openclaw", "main", ["node", "dist/index.js", "devices", "list"]);
934
+ if (code !== 0) {
935
+ log.error("devices list failed");
936
+ return process.exit(1);
937
+ }
906
938
  }
907
939
  catch {
908
940
  log.error("Failed to list device requests");
@@ -918,7 +950,12 @@ export async function openclawPair() {
918
950
  if (p.isCancel(requestId))
919
951
  handleCancel();
920
952
  log.step(`Approving device ${requestId}`);
921
- await execAsync(`kubectl exec -n openclaw deployment/openclaw -c main -- node dist/index.js devices approve "${requestId}"`);
953
+ const kcApprove = k8sApi.kubeConfigFromDefault();
954
+ const approve = await k8sApi.execInDeploymentContainer(kcApprove, "openclaw", "openclaw", "main", ["node", "dist/index.js", "devices", "approve", requestId]);
955
+ if (approve.exitCode !== 0) {
956
+ log.error(approve.stderr || "approve failed");
957
+ return process.exit(1);
958
+ }
922
959
  log.success("Device paired successfully");
923
960
  }
924
961
  // ---------------------------------------------------------------------------
@@ -937,17 +974,17 @@ export async function bootstrap() {
937
974
  console.log();
938
975
  const version = readPackageVersion();
939
976
  const logo = [
940
- " ▄█████▄ ",
941
- " ██ ◆ ██ ",
942
- " ██ ██ ",
943
- " ▀██ ██▀ ",
944
- " ▀█▀ ",
977
+ " ▄█████▄ ",
978
+ " ██ ◆ ██ ",
979
+ " ██ ██ ",
980
+ " ▀██ ██▀ ",
981
+ " ▀█▀ ",
945
982
  ];
946
983
  const taglines = [
947
984
  "💅 Secure, isolated & flexible GitOps infrastructure",
948
985
  "🤖 Manage it yourself — or delegate to AI",
949
- "🔐 Encrypted secrets, hardened containers,",
950
- " continuous delivery",
986
+ "🔐 Encrypted secrets, hardened containers, continuous delivery",
987
+ "",
951
988
  pc.dim(`v${version}`),
952
989
  ];
953
990
  const banner = logo
@@ -956,7 +993,8 @@ export async function bootstrap() {
956
993
  p.box(banner, pc.bold("Welcome to GitOps AI Bootstrapper"), {
957
994
  contentAlign: "left",
958
995
  titleAlign: "center",
959
- rounded: true,
996
+ width: "auto",
997
+ rounded: false,
960
998
  formatBorder: (text) => pc.cyan(text),
961
999
  });
962
1000
  // ── Load saved state ─────────────────────────────────────────────────
@@ -1019,6 +1057,13 @@ export async function bootstrap() {
1019
1057
  ...MONITORING_COMPONENT_IDS,
1020
1058
  ...OPTIONAL_COMPONENTS.map((c) => c.id),
1021
1059
  ];
1060
+ const enableAdditionalFromPlan = prev.enableAdditionalSettings === "true" ||
1061
+ (saved != null &&
1062
+ prev.enableAdditionalSettings === undefined &&
1063
+ ((prev.repoBranch != null && prev.repoBranch !== "" && prev.repoBranch !== "main") ||
1064
+ (prev.templateTag != null &&
1065
+ prev.templateTag !== "" &&
1066
+ prev.templateTag !== "main")));
1022
1067
  const initialState = {
1023
1068
  gitProvider: prev.gitProvider ?? "github",
1024
1069
  setupMode: prev.setupMode ?? "new",
@@ -1029,6 +1074,7 @@ export async function bootstrap() {
1029
1074
  repoName: prev.repoName ?? "fluxcd_ai",
1030
1075
  repoLocalPath: prev.repoLocalPath ?? "",
1031
1076
  repoOwner: prev.repoOwner ?? "",
1077
+ enableAdditionalSettings: enableAdditionalFromPlan,
1032
1078
  repoBranch: prev.repoBranch ?? "main",
1033
1079
  templateTag: prev.templateTag ?? "",
1034
1080
  letsencryptEmail: prev.letsencryptEmail ?? "",
@@ -1040,7 +1086,7 @@ export async function bootstrap() {
1040
1086
  ingressAllowedIps: prev.ingressAllowedIps ?? "0.0.0.0/0",
1041
1087
  clusterPublicIp: prev.clusterPublicIp ?? detectedIp,
1042
1088
  };
1043
- const wizard = await stepWizard(buildFields(detectedIp, !!saved), initialState);
1089
+ const wizard = await stepWizard(buildFields(detectedIp, !!saved, saved), initialState);
1044
1090
  // ── Save config ─────────────────────────────────────────────────────
1045
1091
  saveInstallPlan({
1046
1092
  gitProvider: wizard.gitProvider,
@@ -1056,6 +1102,7 @@ export async function bootstrap() {
1056
1102
  repoName: wizard.repoName,
1057
1103
  repoLocalPath: wizard.repoLocalPath,
1058
1104
  repoOwner: wizard.repoOwner,
1105
+ enableAdditionalSettings: String(wizard.enableAdditionalSettings),
1059
1106
  repoBranch: wizard.repoBranch,
1060
1107
  templateTag: wizard.templateTag,
1061
1108
  cloudflareApiToken: wizard.cloudflareApiToken,
@@ -1064,60 +1111,67 @@ export async function bootstrap() {
1064
1111
  selectedComponents: wizard.selectedComponents.join(","),
1065
1112
  });
1066
1113
  log.success("Configuration saved");
1067
- // ── Warn about CLI tools that will be installed ─────────────────────
1114
+ // ── CLI tools: explain + confirm + install only when something is missing ──
1068
1115
  const toolDescriptions = [
1069
1116
  ["git", "Version control (repo operations)"],
1070
- ["kubectl", "Kubernetes CLI (cluster management)"],
1071
- ["helm", "Kubernetes package manager (chart installs)"],
1072
- ["flux-operator", "FluxCD Operator CLI (GitOps reconciliation)"],
1117
+ ["flux-operator", "FluxCD Operator CLI (installs Flux into the cluster)"],
1073
1118
  ["sops", "Mozilla SOPS (secret encryption)"],
1074
1119
  ["age", "Age encryption (SOPS key backend)"],
1075
1120
  ];
1121
+ if (isMacOS()) {
1122
+ toolDescriptions.push([
1123
+ "docker",
1124
+ "Docker-compatible runtime for k3d",
1125
+ ]);
1126
+ }
1076
1127
  if (isMacOS() || isCI()) {
1077
1128
  toolDescriptions.push(["k3d", "Lightweight K3s in Docker (local cluster)"]);
1078
1129
  }
1079
- const toBeInstalled = toolDescriptions
1080
- .filter(([name]) => !commandExists(name));
1081
- const toolListFormatted = toolDescriptions
1082
- .map(([name, desc]) => {
1083
- const status = commandExists(name)
1084
- ? pc.green("installed")
1085
- : pc.yellow("will install");
1086
- return ` ${pc.bold(name.padEnd(16))} ${pc.dim(desc)} [${status}]`;
1087
- })
1088
- .join("\n");
1089
- const uninstallMac = toolDescriptions
1090
- .map(([name]) => name)
1091
- .join(" ");
1092
- p.note(`${pc.bold("The following CLI tools are required and will be installed if missing:")}\n\n` +
1093
- toolListFormatted +
1094
- "\n\n" +
1095
- pc.dim("─".repeat(60)) + "\n\n" +
1096
- pc.bold("Why are these needed?\n") +
1097
- pc.dim("These tools are used to create and manage your Kubernetes cluster,\n") +
1098
- pc.dim(`deploy components via Helm/Flux, encrypt secrets, and interact with ${providerLabel(wizard.gitProvider)}.\n\n`) +
1099
- pc.bold("How to uninstall later:\n") +
1100
- (isMacOS()
1101
- ? ` ${pc.cyan(`brew uninstall ${uninstallMac}`)}\n`
1102
- : ` ${pc.cyan("sudo rm -f /usr/local/bin/{kubectl,helm,flux-operator,sops,age,age-keygen}")}\n` +
1103
- ` ${pc.cyan(`sudo apt remove -y git`)} ${pc.dim("(if installed via apt)")}\n`) +
1104
- pc.dim("\nAlready-installed tools will be skipped. No system tools will be modified."), "Required CLI Tools");
1105
- const confirmMsg = toBeInstalled.length > 0
1106
- ? `Install ${toBeInstalled.length} missing tool(s) and continue?`
1107
- : "All tools are already installed. Continue?";
1108
- const proceed = await p.confirm({
1109
- message: pc.bold(confirmMsg),
1110
- initialValue: true,
1111
- });
1112
- if (p.isCancel(proceed) || !proceed) {
1113
- log.error("Aborted.");
1114
- return process.exit(1);
1115
- }
1116
- // ── Install all CLI tools upfront ───────────────────────────────────
1117
- if (toBeInstalled.length > 0) {
1130
+ const missingToolNames = toolDescriptions
1131
+ .filter(([name]) => !commandExists(name))
1132
+ .map(([name]) => name);
1133
+ if (missingToolNames.length > 0) {
1134
+ const toolListFormatted = toolDescriptions
1135
+ .map(([name, desc]) => {
1136
+ const status = commandExists(name)
1137
+ ? pc.green("installed")
1138
+ : pc.yellow("will install");
1139
+ return ` ${pc.bold(name.padEnd(16))} ${pc.dim(desc)} [${status}]`;
1140
+ })
1141
+ .join("\n");
1142
+ const uninstallMacFormulae = toolDescriptions
1143
+ .map(([name]) => name)
1144
+ .filter((n) => n !== "docker")
1145
+ .join(" ");
1146
+ const uninstallMac = isMacOS() && toolDescriptions.some(([n]) => n === "docker")
1147
+ ? `brew uninstall ${uninstallMacFormulae} && brew uninstall --cask docker`
1148
+ : `brew uninstall ${uninstallMacFormulae}`;
1149
+ p.note(`${pc.bold("The following CLI tools are required and will be installed if missing:")}\n\n` +
1150
+ toolListFormatted +
1151
+ "\n\n" +
1152
+ pc.dim("─".repeat(60)) + "\n\n" +
1153
+ pc.bold("Why are these needed?\n") +
1154
+ pc.dim("These tools are used to create and manage your Kubernetes cluster,\n") +
1155
+ pc.dim(`install Flux via flux-operator, encrypt secrets, and interact with ${providerLabel(wizard.gitProvider)}.\n`) +
1156
+ pc.bold("How to uninstall later:\n") +
1157
+ (isMacOS()
1158
+ ? ` ${pc.cyan(`brew uninstall ${uninstallMac}`)}\n`
1159
+ : ` ${pc.cyan("sudo rm -f /usr/local/bin/{flux-operator,sops,age,age-keygen}")}\n` +
1160
+ ` ${pc.cyan(`sudo apt remove -y git`)} ${pc.dim("(if installed via apt)")}\n`) +
1161
+ pc.dim("\nAlready-installed tools will be skipped. No system tools will be modified."), "Required CLI Tools");
1162
+ const proceed = await p.confirm({
1163
+ message: pc.bold(`Install ${missingToolNames.length} missing tool(s) and continue?`),
1164
+ initialValue: true,
1165
+ });
1166
+ if (p.isCancel(proceed) || !proceed) {
1167
+ log.error("Aborted.");
1168
+ return process.exit(1);
1169
+ }
1118
1170
  log.step("Installing CLI tools");
1119
- const allToolNames = toolDescriptions.map(([name]) => name);
1120
- await ensureAll(allToolNames);
1171
+ await ensureAll(missingToolNames);
1172
+ }
1173
+ if (isMacOS()) {
1174
+ await ensureDockerDaemonReady();
1121
1175
  }
1122
1176
  // ── Repo creation phase (new mode only) ─────────────────────────────
1123
1177
  let repoRoot;
@@ -1158,15 +1212,9 @@ export async function bootstrap() {
1158
1212
  (isNewRepo(wizard) ? "main" : undefined),
1159
1213
  };
1160
1214
  // ── Check macOS prerequisites ────────────────────────────────────────
1161
- if (isMacOS()) {
1162
- if (!commandExists("brew")) {
1163
- log.error("Homebrew is required on macOS. Install from https://brew.sh");
1164
- return process.exit(1);
1165
- }
1166
- if (!commandExists("docker")) {
1167
- log.error("Docker is required on macOS (Docker Desktop, OrbStack, or Colima).");
1168
- return process.exit(1);
1169
- }
1215
+ if (isMacOS() && !commandExists("brew")) {
1216
+ log.error("Homebrew is required on macOS. Install from https://brew.sh");
1217
+ return process.exit(1);
1170
1218
  }
1171
1219
  // ── Run bootstrap (cluster + flux + template + sops + git push) ─────
1172
1220
  try {
@@ -1224,8 +1272,15 @@ export async function bootstrap() {
1224
1272
  summary("Bootstrap Complete", summaryEntries);
1225
1273
  const finalSteps = [
1226
1274
  `All HelmReleases may take ${pc.yellow("~5 minutes")} to become ready.`,
1227
- `Check status: ${pc.cyan("kubectl get helmreleases -A")}`,
1228
1275
  ];
1276
+ if (commandExists("kubectl")) {
1277
+ finalSteps.push(`Check HelmRelease status: ${pc.cyan("kubectl get helmreleases -A")}`);
1278
+ }
1279
+ else {
1280
+ finalSteps.push(`Install ${pc.bold("kubectl")} to check HelmRelease status: ${isMacOS()
1281
+ ? pc.cyan("brew install kubectl")
1282
+ : pc.cyan("https://kubernetes.io/docs/tasks/tools/")}`);
1283
+ }
1229
1284
  if (!commandExists("k9s")) {
1230
1285
  finalSteps.push(`Install ${pc.bold("k9s")} for a terminal UI to monitor your cluster: ${isMacOS()
1231
1286
  ? pc.cyan("brew install derailed/k9s/k9s")