@pleri/olam-cli 0.1.166 → 0.1.168

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.
Files changed (67) hide show
  1. package/README.md +4 -2
  2. package/dist/commands/bootstrap.d.ts +6 -0
  3. package/dist/commands/bootstrap.d.ts.map +1 -1
  4. package/dist/commands/bootstrap.js +15 -0
  5. package/dist/commands/bootstrap.js.map +1 -1
  6. package/dist/commands/doctor.js +4 -4
  7. package/dist/commands/doctor.js.map +1 -1
  8. package/dist/commands/init.d.ts +4 -3
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +103 -81
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/memory-service-container.d.ts +8 -0
  13. package/dist/commands/memory-service-container.d.ts.map +1 -1
  14. package/dist/commands/memory-service-container.js +16 -1
  15. package/dist/commands/memory-service-container.js.map +1 -1
  16. package/dist/commands/plans.d.ts +3 -0
  17. package/dist/commands/plans.d.ts.map +1 -0
  18. package/dist/commands/plans.js +211 -0
  19. package/dist/commands/plans.js.map +1 -0
  20. package/dist/commands/setup.d.ts +78 -14
  21. package/dist/commands/setup.d.ts.map +1 -1
  22. package/dist/commands/setup.js +430 -42
  23. package/dist/commands/setup.js.map +1 -1
  24. package/dist/commands/skills-source.d.ts +24 -0
  25. package/dist/commands/skills-source.d.ts.map +1 -1
  26. package/dist/commands/skills-source.js +257 -18
  27. package/dist/commands/skills-source.js.map +1 -1
  28. package/dist/commands/skills.d.ts +21 -0
  29. package/dist/commands/skills.d.ts.map +1 -1
  30. package/dist/commands/skills.js +44 -0
  31. package/dist/commands/skills.js.map +1 -1
  32. package/dist/image-digests.json +8 -7
  33. package/dist/index.js +2494 -1279
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/bootstrap-kubernetes.d.ts.map +1 -1
  36. package/dist/lib/bootstrap-kubernetes.js +178 -107
  37. package/dist/lib/bootstrap-kubernetes.js.map +1 -1
  38. package/dist/lib/health-probes.d.ts +16 -0
  39. package/dist/lib/health-probes.d.ts.map +1 -1
  40. package/dist/lib/health-probes.js +49 -0
  41. package/dist/lib/health-probes.js.map +1 -1
  42. package/dist/lib/peripheral-registry.d.ts +9 -3
  43. package/dist/lib/peripheral-registry.d.ts.map +1 -1
  44. package/dist/lib/peripheral-registry.js +4 -4
  45. package/dist/lib/peripheral-registry.js.map +1 -1
  46. package/dist/lib/plans-client.d.ts +69 -0
  47. package/dist/lib/plans-client.d.ts.map +1 -0
  48. package/dist/lib/plans-client.js +137 -0
  49. package/dist/lib/plans-client.js.map +1 -0
  50. package/dist/lib/port-forward.js +1 -1
  51. package/dist/lib/port-forward.js.map +1 -1
  52. package/dist/lib/upgrade-kubernetes.d.ts +1 -1
  53. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  54. package/dist/lib/upgrade-kubernetes.js +35 -21
  55. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  56. package/dist/mcp-server.js +1239 -343
  57. package/hermes-bundle/version.json +1 -1
  58. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  59. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  60. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  61. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  62. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  63. package/host-cp/src/halt-detect.mjs +43 -0
  64. package/host-cp/src/panic-counter.mjs +94 -0
  65. package/host-cp/src/plan-chat-service.mjs +12 -1
  66. package/host-cp/src/server.mjs +75 -0
  67. package/package.json +1 -1
@@ -1,20 +1,35 @@
1
1
  /**
2
- * olam setup — 7-phase fresh-host wizard.
2
+ * olam setup — substrate-aware fresh-host wizard.
3
3
  *
4
4
  * Phase C2 of olam-operator-onboarding-parity (plan
5
5
  * ~/.claude/plans/olam-operator-onboarding-parity.md).
6
6
  *
7
- * Orchestrates existing olam primitives (Decision 2 narrow scope; does
8
- * NOT install Docker / Node / Homebrew / Claude Code; preserves olam's
9
- * "operator owns host, olam owns substrate" posture):
7
+ * Substrate-aware orchestration. Default substrate = kubernetes (k3d on all
8
+ * platforms macOS and Linux alike). Existing installs whose ~/.olam/config.json
9
+ * carries host.substrate: 'compose' are protected — they continue on docker/compose
10
+ * and see a one-line migration hint; no auto-migration.
10
11
  *
11
- * Phase 1: System check (Docker daemon reachable + Node ≥20)
12
- * Phase 2: olam CLI sanity (--version returns)
13
- * Phase 3: Bootstrap (subprocess: olam bootstrap)
14
- * Phase 4: Shell init (idempotent append of completion eval-line)
15
- * Phase 5: Init project (offer `olam init` if .olam/config.yaml missing)
16
- * Phase 6: Auth prompt (offer interactive `olam auth login`)
17
- * Phase 7: Final verify (subprocess: olam doctor; halt on non-zero)
12
+ * Kubernetes substrate phases (default for fresh installs):
13
+ * Phase 1: System check (kubectl on PATH + docker daemon + Node ≥20)
14
+ * Phase 1.5: Install k3d (brew install k3d when available; else upstream install.sh)
15
+ * Phase 2: olam CLI sanity (--version returns)
16
+ * Phase 2.5: Provision cluster (k3d cluster create olam-dev; k3d updates ~/.kube/config)
17
+ * Phase 2.6: Pin kubectl context (writes host.kubectl_context_pinned = k3d-olam-dev)
18
+ * Phase 3: Bootstrap (subprocess: olam bootstrap with kubernetes substrate)
19
+ * … same as docker thereafter
20
+ *
21
+ * Docker substrate phases (existing compose installs or --substrate=docker):
22
+ * Phase 1: System check (Docker daemon reachable + Node ≥20)
23
+ * Phase 1.5: (no-op for docker)
24
+ * Phase 2: olam CLI sanity (--version returns)
25
+ * Phase 2.5: (no-op for docker)
26
+ * Phase 3: Bootstrap (subprocess: olam bootstrap)
27
+ * Phase 4: Shell init (idempotent append of completion eval-line)
28
+ * Phase 5: Init project (offer `olam init` if .olam/config.yaml missing)
29
+ * Phase 5a: Skill source picker
30
+ * Phase 5b: Project sweep
31
+ * Phase 6: Auth prompt (offer interactive `olam auth login`)
32
+ * Phase 7: Final verify (subprocess: olam doctor; halt on non-zero)
18
33
  *
19
34
  * End-state prints "next steps" pointing at the 3-contract docs per
20
35
  * CLAUDE.md's "Reading order for new orgs."
@@ -23,30 +38,42 @@
23
38
  * a named remedy. All phases are idempotent so re-running is safe.
24
39
  *
25
40
  * Flags:
26
- * --skip-shell-init skip Phase 4 (operator manages shell rc manually)
27
- * --skip-auth skip Phase 6 (operator will run `olam auth login` later)
28
- * --yes / -y auto-affirm every prompt (non-interactive)
41
+ * --substrate=<docker|kubernetes> substrate to target (default: kubernetes; alias k3s→kubernetes)
42
+ * --cluster-name=<name> k3d cluster name (default: olam-dev). Useful for operators with
43
+ * pre-existing clusters or running multiple olam stacks side-by-side.
44
+ * --skip-shell-init skip Phase 4 (operator manages shell rc manually)
45
+ * --skip-auth skip Phase 6 (operator will run `olam auth login` later)
46
+ * --yes / -y auto-affirm every prompt (non-interactive)
29
47
  *
30
48
  * Reversibility: clean-revert; each phase delegates to a separately-
31
49
  * tested primitive. Phase 4 keeps a `<rc>.olam-bak.<ts>` backup.
32
50
  */
33
- import { spawn } from 'node:child_process';
34
- import { existsSync } from 'node:fs';
51
+ import { spawn, spawnSync } from 'node:child_process';
52
+ import { existsSync, readFileSync } from 'node:fs';
35
53
  import { homedir } from 'node:os';
36
54
  import path from 'node:path';
37
55
  import { createInterface } from 'node:readline';
38
56
  import { readCliVersion } from '../cli-version.js';
39
- import { probeDockerDaemon } from '../lib/health-probes.js';
57
+ import { probeDockerDaemon, probeComposePlugin, probeKubectl, probeK3d, } from '../lib/health-probes.js';
40
58
  import { appendIdempotent, resolveShellRc } from '../lib/shell-rc.js';
41
- import { findProjectRoot } from './init.js';
59
+ import { applyKubectlContextPin, findProjectRoot } from './init.js';
42
60
  import { printError, printHeader, printInfo, printSuccess, printWarning } from '../output.js';
43
61
  import { pickSkillSourcePhase, } from './setup-phase-5a-skill-source.js';
44
62
  import { runProjectSweepPhase } from './setup-phase-5b-project-sweep.js';
63
+ import { OLAM_CONFIG_PATH, writeConfig } from '../lib/config.js';
45
64
  const REQUIRED_NODE_MAJOR = 20;
46
- const NEXT_STEPS_DOCS = [
47
- 'docs/architecture/devbox-contract.md — image contract',
48
- 'docs/architecture/manifest-spec.md — per-repo .adb.yaml schema',
49
- 'docs/architecture/config-spec.md workspace .olam/config.yaml schema',
65
+ /** k3d cluster name provisioned by olam setup --substrate=kubernetes. */
66
+ export const SETUP_K3D_CLUSTER_NAME = 'olam-dev';
67
+ const NEXT_STEPS_DOCS_DOCKER = [
68
+ 'https://github.com/pleri/olam/blob/main/docs/architecture/devbox-contract.md image contract',
69
+ 'https://github.com/pleri/olam/blob/main/docs/architecture/manifest-spec.md — per-repo .adb.yaml schema',
70
+ 'https://github.com/pleri/olam/blob/main/docs/architecture/config-spec.md — workspace .olam/config.yaml schema',
71
+ ];
72
+ const NEXT_STEPS_DOCS_KUBERNETES = [
73
+ 'https://github.com/pleri/olam/blob/main/docs/architecture/devbox-contract.md — image contract',
74
+ 'https://github.com/pleri/olam/blob/main/docs/architecture/manifest-spec.md — per-repo .adb.yaml schema',
75
+ 'https://github.com/pleri/olam/blob/main/docs/architecture/config-spec.md — workspace .olam/config.yaml schema',
76
+ 'https://github.com/pleri/olam/blob/main/docs/k8s/SETUP.md — k3d operator guide',
50
77
  ];
51
78
  // ── Default deps ───────────────────────────────────────────────────────
52
79
  const defaultSpawn = (cmd, args) => new Promise((resolve) => {
@@ -83,15 +110,114 @@ const defaultPrompt = (question, defaultYes) => {
83
110
  rl.on('close', () => resolve(defaultYes));
84
111
  });
85
112
  };
113
+ /** k3d / DNS-label name validation — same rules k3d enforces. */
114
+ const CLUSTER_NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
115
+ /**
116
+ * Validate the --cluster-name value.
117
+ * Returns an error message when invalid, or null when valid.
118
+ */
119
+ export function validateClusterName(name) {
120
+ if (name.length === 0)
121
+ return 'cluster name must not be empty';
122
+ if (name.length > 63)
123
+ return 'cluster name must be ≤63 characters (DNS label limit)';
124
+ if (!CLUSTER_NAME_RE.test(name)) {
125
+ return (`cluster name '${name}' is invalid — must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ `
126
+ + '(lowercase alphanumeric, hyphens allowed in the middle)');
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Resolve the effective cluster name.
132
+ * Explicit opt takes priority; falls back to the constant default.
133
+ */
134
+ function resolveClusterName(opts) {
135
+ return opts.clusterName ?? SETUP_K3D_CLUSTER_NAME;
136
+ }
137
+ // ── Substrate resolution ───────────────────────────────────────────────────
138
+ /** Migration hint printed once when an existing docker/compose install is detected. */
139
+ export const DOCKER_MIGRATION_HINT = 'olam: detected existing docker stack — continuing on docker. '
140
+ + 'To migrate to k3d, run: olam upgrade --substrate=kubernetes (available in a future release).';
141
+ /**
142
+ * Resolve the effective substrate: explicit opt has highest priority, then
143
+ * auto-detect from ~/.olam/config.json (or override path), then default
144
+ * 'kubernetes' for fresh installs.
145
+ *
146
+ * The --substrate=k3s alias normalises to 'kubernetes'.
147
+ *
148
+ * Existing-install guard: when the config has host.substrate='compose' (the
149
+ * old docker default) and no explicit flag is provided, the wizard continues
150
+ * on 'docker' and prints a one-line migration hint. No auto-migration.
151
+ */
152
+ function resolveSubstrate(opts, deps) {
153
+ if (opts.substrate === 'kubernetes')
154
+ return 'kubernetes';
155
+ if (opts.substrate === 'docker')
156
+ return 'docker';
157
+ // Auto-detect from config file
158
+ const configPath = deps.configPath ?? OLAM_CONFIG_PATH;
159
+ if (existsSync(configPath)) {
160
+ try {
161
+ const raw = readFileSync(configPath, 'utf8');
162
+ const parsed = JSON.parse(raw);
163
+ const host = parsed.host;
164
+ if (host?.substrate === 'kubernetes')
165
+ return 'kubernetes';
166
+ if (host?.substrate === 'compose') {
167
+ // Existing docker/compose install — protect it from the default flip.
168
+ process.stderr.write(DOCKER_MIGRATION_HINT + '\n');
169
+ return 'docker';
170
+ }
171
+ }
172
+ catch {
173
+ // malformed config — fall through to new default
174
+ }
175
+ }
176
+ // Fresh install (no config file, or unrecognised substrate) → new default
177
+ return 'kubernetes';
178
+ }
86
179
  // ── Phases ─────────────────────────────────────────────────────────────
87
- async function phase1SystemCheck(deps) {
180
+ async function phase1SystemCheck(substrate, deps) {
181
+ const nodeVersion = deps.nodeVersion ?? process.version;
182
+ const nodeMajor = parseNodeMajor(nodeVersion);
183
+ const nodeOk = nodeMajor !== null && nodeMajor >= REQUIRED_NODE_MAJOR;
184
+ if (substrate === 'kubernetes') {
185
+ // kubectl is required on all platforms.
186
+ const kubectlProbe = await probeKubectl(deps.dockerExec);
187
+ if (!kubectlProbe.ok) {
188
+ return phaseFromProbe(kubectlProbe);
189
+ }
190
+ // k3d runs inside Docker on all platforms (macOS and Linux alike).
191
+ const dockerProbe = await probeDockerDaemon(deps.dockerExec);
192
+ if (!dockerProbe.ok) {
193
+ return {
194
+ ok: false,
195
+ message: 'docker daemon required for k3d: ' + dockerProbe.message,
196
+ remedy: dockerProbe.remedy,
197
+ };
198
+ }
199
+ if (!nodeOk) {
200
+ return {
201
+ ok: false,
202
+ message: `Node.js ${nodeVersion} is too old (need ≥${REQUIRED_NODE_MAJOR})`,
203
+ remedy: `Install Node.js ${REQUIRED_NODE_MAJOR}+ LTS via nvm/fnm/asdf and re-run \`olam setup\`.`,
204
+ };
205
+ }
206
+ return { ok: true, message: `kubectl on PATH; docker ready; node ${nodeVersion}` };
207
+ }
208
+ // Docker substrate (default)
88
209
  const dockerProbe = await probeDockerDaemon(deps.dockerExec);
89
210
  if (!dockerProbe.ok) {
90
211
  return phaseFromProbe(dockerProbe);
91
212
  }
92
- const nodeVersion = deps.nodeVersion ?? process.version;
93
- const nodeMajor = parseNodeMajor(nodeVersion);
94
- if (nodeMajor === null || nodeMajor < REQUIRED_NODE_MAJOR) {
213
+ // Verify docker compose v2 plugin. Linux operators who installed docker.io
214
+ // (or via `brew install docker` on macOS) without the compose plugin will
215
+ // hit cryptic "unknown command" errors later; fail early with a clear remedy.
216
+ const composeProbe = await probeComposePlugin(deps.dockerExec);
217
+ if (!composeProbe.ok) {
218
+ return phaseFromProbe(composeProbe);
219
+ }
220
+ if (!nodeOk) {
95
221
  return {
96
222
  ok: false,
97
223
  message: `Node.js ${nodeVersion} is too old (need ≥${REQUIRED_NODE_MAJOR})`,
@@ -100,9 +226,80 @@ async function phase1SystemCheck(deps) {
100
226
  }
101
227
  return {
102
228
  ok: true,
103
- message: `${dockerProbe.message}; node ${nodeVersion}`,
229
+ message: `${dockerProbe.message}; ${composeProbe.message}; node ${nodeVersion}`,
104
230
  };
105
231
  }
232
+ /**
233
+ * Phase 1.5 — Install missing substrate tools (idempotent).
234
+ *
235
+ * Docker: no-op.
236
+ * Kubernetes (all platforms): installs k3d via brew when available, else the
237
+ * upstream install script. k3d works on both macOS and Linux — no sudo needed
238
+ * (k3d only requires docker, which Phase 1 already confirmed).
239
+ */
240
+ async function phase1_5InstallSubstrate(substrate, opts, deps) {
241
+ if (substrate === 'docker') {
242
+ return { ok: true, skipped: true, message: 'no-op for docker substrate' };
243
+ }
244
+ const spawnFn = deps.spawnSubprocess ?? defaultSpawn;
245
+ const promptFn = deps.prompt ?? defaultPrompt;
246
+ // k3d works on both darwin and linux; one path for both.
247
+ // Requires docker daemon (Phase 1 already checks this).
248
+ const k3dProbe = await probeK3d(deps.dockerExec);
249
+ if (k3dProbe.ok) {
250
+ return { ok: true, message: `k3d already present: ${k3dProbe.message}` };
251
+ }
252
+ // Determine installer: brew when available (common on macOS, sometimes on Linux),
253
+ // else upstream install script (no sudo needed).
254
+ const hasBrew = spawnSync('command', ['-v', 'brew'], { shell: true, stdio: 'pipe' }).status === 0;
255
+ const useBrewMsg = hasBrew ? 'Homebrew' : 'upstream install script';
256
+ if (!opts.yes) {
257
+ const confirmed = await promptFn(`k3d is not installed. Install via ${useBrewMsg}?`, true);
258
+ if (!confirmed) {
259
+ return {
260
+ ok: false,
261
+ message: 'k3d install declined; required for kubernetes substrate',
262
+ remedy: 'Install manually: `brew install k3d` or curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash, '
263
+ + 'then re-run `olam setup --substrate=kubernetes`.',
264
+ };
265
+ }
266
+ }
267
+ if (hasBrew) {
268
+ process.stdout.write('Installing k3d via Homebrew...\n');
269
+ const r = await spawnFn('brew', ['install', 'k3d']);
270
+ if (r.status !== 0) {
271
+ return {
272
+ ok: false,
273
+ message: `brew install k3d failed (exit ${r.status ?? 'signal'})`,
274
+ remedy: 'Try manually: `brew install k3d` or curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash',
275
+ };
276
+ }
277
+ }
278
+ else {
279
+ process.stdout.write('Installing k3d via upstream install script...\n');
280
+ const r = await spawnFn('bash', [
281
+ '-c',
282
+ 'curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash',
283
+ ]);
284
+ if (r.status !== 0) {
285
+ return {
286
+ ok: false,
287
+ message: `k3d upstream install failed (exit ${r.status ?? 'signal'})`,
288
+ remedy: 'Install manually: https://k3d.io/v5.x/installation/',
289
+ };
290
+ }
291
+ }
292
+ // Verify k3d is now on PATH
293
+ const k3dCheck = await probeK3d(deps.dockerExec);
294
+ if (!k3dCheck.ok) {
295
+ return {
296
+ ok: false,
297
+ message: 'k3d still not on PATH after install — shell PATH may need updating',
298
+ remedy: 'Close and re-open your terminal, then re-run `olam setup --substrate=kubernetes`.',
299
+ };
300
+ }
301
+ return { ok: true, message: `k3d installed: ${k3dCheck.message}` };
302
+ }
106
303
  function parseNodeMajor(version) {
107
304
  const m = version.match(/^v?(\d+)\./);
108
305
  if (!m)
@@ -115,6 +312,74 @@ function phaseFromProbe(probe) {
115
312
  return { ok: true, message: probe.message };
116
313
  return { ok: false, message: probe.message, remedy: probe.remedy };
117
314
  }
315
+ /**
316
+ * Phase 2.5 — Provision k3d cluster (all platforms).
317
+ * No-op for docker substrate.
318
+ * Idempotent: if the cluster already exists, logs and skips.
319
+ * k3d updates ~/.kube/config automatically — no kubeconfig juggling needed.
320
+ *
321
+ * @param clusterName - resolved cluster name (default: SETUP_K3D_CLUSTER_NAME)
322
+ */
323
+ async function phase2_5ProvisionCluster(substrate, clusterName, opts, deps) {
324
+ if (substrate === 'docker') {
325
+ return { ok: true, skipped: true, message: 'no-op for docker substrate' };
326
+ }
327
+ const spawnFn = deps.spawnSubprocess ?? defaultSpawn;
328
+ // k3d cluster create <clusterName> (idempotent — same on macOS and Linux)
329
+ // Check if cluster already exists
330
+ const listResult = await captureSpawn('k3d', ['cluster', 'list', '--output', 'json'], deps.dockerExec);
331
+ if (listResult.ok) {
332
+ const exists = clusterExistsInList(listResult.stdout, clusterName);
333
+ if (exists) {
334
+ return {
335
+ ok: true,
336
+ message: `cluster ${clusterName} already exists; skipping create`,
337
+ };
338
+ }
339
+ }
340
+ process.stdout.write(`Creating k3d cluster ${clusterName}...\n`);
341
+ const ghConfigBind = `${homedir()}/.config/gh:/host/.config/gh`;
342
+ const createResult = await spawnFn('k3d', [
343
+ 'cluster', 'create', clusterName,
344
+ '--volume', ghConfigBind,
345
+ '--wait', '--timeout', '90s',
346
+ ]);
347
+ if (createResult.status !== 0) {
348
+ return {
349
+ ok: false,
350
+ message: `k3d cluster create ${clusterName} failed (exit ${createResult.status ?? 'signal'})`,
351
+ remedy: `Run manually: k3d cluster create ${clusterName} --wait --timeout 90s`,
352
+ };
353
+ }
354
+ // Verify kubectl can reach it (k3d updates ~/.kube/config automatically)
355
+ const ctxResult = captureSpawnSync('kubectl', ['config', 'current-context']);
356
+ const ctx = ctxResult.ok ? ctxResult.stdout.trim() : '(unknown)';
357
+ return { ok: true, message: `cluster ${clusterName} created; context: ${ctx}` };
358
+ }
359
+ /** Capture-only spawn via dockerExec (no subprocess) for list/query operations. */
360
+ async function captureSpawn(cmd, args, dockerExec) {
361
+ const exec = dockerExec ?? ((c, a) => {
362
+ const r = spawnSync(c, [...a], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
363
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
364
+ });
365
+ const r = exec(cmd, args);
366
+ return { ok: r.status === 0, stdout: r.stdout, stderr: r.stderr };
367
+ }
368
+ /** Synchronous capture spawn used for non-critical reads (cluster context). */
369
+ function captureSpawnSync(cmd, args) {
370
+ const r = spawnSync(cmd, [...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
371
+ return { ok: r.status === 0, stdout: r.stdout ?? '' };
372
+ }
373
+ /** Parse k3d cluster list JSON and return true if clusterName is present. */
374
+ function clusterExistsInList(json, clusterName) {
375
+ try {
376
+ const parsed = JSON.parse(json);
377
+ return Array.isArray(parsed) && parsed.some((c) => c.name === clusterName);
378
+ }
379
+ catch {
380
+ return false;
381
+ }
382
+ }
118
383
  async function phase2CliSanity(deps) {
119
384
  // CRITICAL fix (Phase C CP3): readCliVersion() is the canonical
120
385
  // source (same helper `index.ts:55` uses for `--version`). The
@@ -140,16 +405,72 @@ function safeReadCliVersion() {
140
405
  return null;
141
406
  }
142
407
  }
143
- async function phase3Bootstrap(deps) {
408
+ /**
409
+ * Phase 2.6 — Pin kubectl context after k3d cluster creation.
410
+ * No-op for docker substrate.
411
+ *
412
+ * `olam upgrade` (called by bootstrap step 6/6) requires
413
+ * host.kubectl_context_pinned in ~/.olam/config.json. On the kubernetes
414
+ * substrate, Phase 2.5 creates the cluster (context = k3d-<cluster>),
415
+ * but the standard `olam init` that writes this field runs in Phase 5
416
+ * (AFTER bootstrap). This phase bridges the gap: it pins the context
417
+ * immediately after cluster creation so bootstrap/upgrade can proceed.
418
+ *
419
+ * Idempotent: applyKubectlContextPin skips when the field is already set.
420
+ *
421
+ * @param clusterName - resolved cluster name (default: SETUP_K3D_CLUSTER_NAME)
422
+ */
423
+ async function phase2_6PinKubectlContext(substrate, clusterName, deps) {
424
+ if (substrate !== 'kubernetes') {
425
+ return { ok: true, skipped: true, message: 'no-op for docker substrate' };
426
+ }
427
+ // k3d names the context `k3d-<cluster-name>`.
428
+ const expectedContext = `k3d-${clusterName}`;
429
+ // Ensure ~/.olam/config.json exists with a valid schema before patching
430
+ // kubectl_context_pinned. applyKubectlContextPin preserves existing keys but
431
+ // starts from `{}` when the file is absent — that produces an invalid schema
432
+ // (`readConfig` warns and falls back to defaults, losing kubectl_context_pinned).
433
+ // writeConfig reads current config (or creates a valid default) and writes a
434
+ // schema-conformant file. The subsequent applyKubectlContextPin will then
435
+ // patch kubectl_context_pinned on top of that valid foundation.
436
+ writeConfig({ host: { substrate: 'kubernetes' } }, { configPath: deps.configPath });
437
+ const result = applyKubectlContextPin([expectedContext], {
438
+ configPath: deps.configPath,
439
+ });
440
+ if ('pinned' in result) {
441
+ return { ok: true, message: `kubectl context pinned: ${result.pinned}` };
442
+ }
443
+ if ('skipped' in result) {
444
+ return { ok: true, message: `kubectl context already pinned (${result.skipped})` };
445
+ }
446
+ // 'refused' — multiple contexts (should not happen since we pass exactly one)
447
+ return {
448
+ ok: false,
449
+ message: `kubectl context pin refused: ${result.refused}`,
450
+ remedy: `Set host.kubectl_context_pinned = ${expectedContext} in ~/.olam/config.json and re-run.`,
451
+ };
452
+ }
453
+ async function phase3Bootstrap(substrate, deps) {
144
454
  // HIGH fix (Phase C CP3): pass --skip-auth-login. `olam bootstrap`
145
455
  // step 6 (bootstrap.ts:499-520) runs PKCE interactively by default.
146
456
  // Phase 6 owns the operator-facing auth UX (with non-fatal recovery);
147
457
  // letting bootstrap run auth too means double-prompt + collapses
148
458
  // Phase 6's recovery affordance when auth fails during bootstrap.
459
+ //
460
+ // Substrate-aware: the bootstrap command reads ~/.olam/config.json for
461
+ // substrate dispatch. For kubernetes substrate we additionally pass
462
+ // --skip-cluster-create (Phase 2.5 already created it) and
463
+ // --skip-observability is NOT passed — bootstrap still installs observability.
149
464
  const spawnFn = deps.spawnSubprocess ?? defaultSpawn;
150
- const r = await spawnFn('olam', ['bootstrap', '--skip-auth-login']);
465
+ const bootstrapArgs = ['bootstrap', '--skip-auth-login'];
466
+ if (substrate === 'kubernetes') {
467
+ // Cluster was already created by Phase 2.5; bootstrap's preflight
468
+ // and cluster-create steps should skip.
469
+ bootstrapArgs.push('--skip-cluster-create');
470
+ }
471
+ const r = await spawnFn('olam', bootstrapArgs);
151
472
  if (r.status === 0) {
152
- return { ok: true, message: 'olam bootstrap succeeded' };
473
+ return { ok: true, message: `olam bootstrap succeeded (substrate: ${substrate})` };
153
474
  }
154
475
  return {
155
476
  ok: false,
@@ -271,7 +592,10 @@ async function phase6Auth(opts, deps) {
271
592
  message: `olam auth login exited ${r.status}; re-run \`olam auth login\` after resolving`,
272
593
  };
273
594
  }
274
- async function phase7Verify(deps) {
595
+ async function phase7Verify(opts, deps) {
596
+ if (opts.skipDoctor) {
597
+ return { ok: true, skipped: true, message: 'skipped via --skip-doctor' };
598
+ }
275
599
  const spawnFn = deps.spawnSubprocess ?? defaultSpawn;
276
600
  const r = await spawnFn('olam', ['doctor']);
277
601
  if (r.status === 0) {
@@ -284,9 +608,12 @@ async function phase7Verify(deps) {
284
608
  };
285
609
  }
286
610
  // ── Orchestrator ──────────────────────────────────────────────────────
287
- const PHASE_TITLES = [
611
+ const PHASE_TITLES_DOCKER = [
288
612
  'Phase 1: System check',
613
+ 'Phase 1.5: Substrate tools install',
289
614
  'Phase 2: olam CLI sanity',
615
+ 'Phase 2.5: Cluster provision',
616
+ 'Phase 2.6: Pin kubectl context',
290
617
  'Phase 3: Bootstrap',
291
618
  'Phase 4: Shell init',
292
619
  'Phase 5: Init project',
@@ -295,23 +622,56 @@ const PHASE_TITLES = [
295
622
  'Phase 6: Auth prompt',
296
623
  'Phase 7: Final verification',
297
624
  ];
625
+ // For backward compatibility the phases array has the same indices
626
+ // as before when substrate=docker (1.5 and 2.5 are no-ops/skipped).
627
+ // The phase count is 11 not 9 but existing tests that assert on
628
+ // phase indices are updated to use the new positions.
298
629
  export async function runSetup(opts, deps = {}) {
299
- printHeader('olam setup');
630
+ const substrate = resolveSubstrate(opts, deps);
631
+ const clusterName = resolveClusterName(opts);
632
+ // Phase 0: flag validation (fail fast before any I/O).
633
+ // Only validate when the kubernetes substrate is in play — the flag is a
634
+ // no-op for docker and should not block docker-only operators who pass it
635
+ // accidentally, but we still validate the format to surface typos.
636
+ if (opts.clusterName !== undefined) {
637
+ const nameError = validateClusterName(opts.clusterName);
638
+ if (nameError !== null) {
639
+ const errResult = {
640
+ ok: false,
641
+ message: `--cluster-name: ${nameError}`,
642
+ remedy: 'Use a DNS-compatible name: lowercase letters, digits, hyphens in the middle. '
643
+ + 'Example: olam-test, my-stack-1',
644
+ };
645
+ printError(` ✗ ${errResult.message}`);
646
+ if (errResult.remedy)
647
+ printWarning(` remedy: ${errResult.remedy}`);
648
+ return { phases: [{ name: 'Phase 0: Flag validation', result: errResult }], failureAt: 1, exitCode: 1 };
649
+ }
650
+ }
651
+ const bannerSubstrate = substrate === 'kubernetes' ? 'Kubernetes (k3d)' : 'Docker Compose';
652
+ printHeader(`olam setup — Olam local stack on ${bannerSubstrate}`);
653
+ process.stdout.write(`substrate: ${substrate}\n`);
654
+ if (substrate === 'kubernetes') {
655
+ process.stdout.write(`cluster: ${clusterName}\n`);
656
+ }
300
657
  const phaseFns = [
301
- () => phase1SystemCheck(deps),
658
+ () => phase1SystemCheck(substrate, deps),
659
+ () => phase1_5InstallSubstrate(substrate, opts, deps),
302
660
  () => phase2CliSanity(deps),
303
- () => phase3Bootstrap(deps),
661
+ () => phase2_5ProvisionCluster(substrate, clusterName, opts, deps),
662
+ () => phase2_6PinKubectlContext(substrate, clusterName, deps),
663
+ () => phase3Bootstrap(substrate, deps),
304
664
  () => phase4ShellInit(opts, deps),
305
665
  () => phase5InitProject(opts, deps),
306
666
  () => phase5aSkillSource(opts, deps),
307
667
  () => phase5bProjectSweep(opts, deps),
308
668
  () => phase6Auth(opts, deps),
309
- () => phase7Verify(deps),
669
+ () => phase7Verify(opts, deps),
310
670
  ];
311
671
  const results = [];
312
672
  let failureAt = null;
313
673
  for (let i = 0; i < phaseFns.length; i += 1) {
314
- const name = PHASE_TITLES[i];
674
+ const name = PHASE_TITLES_DOCKER[i];
315
675
  process.stdout.write(`\n${name}\n`);
316
676
  const result = await phaseFns[i]();
317
677
  results.push({ name, result });
@@ -336,7 +696,8 @@ export async function runSetup(opts, deps = {}) {
336
696
  }
337
697
  printSuccess('Setup complete.');
338
698
  process.stdout.write('\nNext steps — read these in order for the 3-contract pattern:\n');
339
- for (const line of NEXT_STEPS_DOCS) {
699
+ const nextStepsDocs = substrate === 'kubernetes' ? NEXT_STEPS_DOCS_KUBERNETES : NEXT_STEPS_DOCS_DOCKER;
700
+ for (const line of nextStepsDocs) {
340
701
  printInfo('docs', line);
341
702
  }
342
703
  process.stdout.write('\n');
@@ -345,8 +706,17 @@ export async function runSetup(opts, deps = {}) {
345
706
  export function registerSetup(program) {
346
707
  program
347
708
  .command('setup')
348
- .description('Fresh-host onboarding wizard. Runs 7 phases (system check bootstrap → shell init → '
349
- + 'init project auth prompt doctor verification). Idempotent; safe to re-run.')
709
+ .description('Fresh-host onboarding wizard. Default substrate=kubernetes (k3d on all platforms): '
710
+ + 'installs k3d (no sudo needed only requires docker), provisions the olam-dev cluster, '
711
+ + 'then runs bootstrap end-to-end. '
712
+ + 'Existing docker/compose installs are protected — they continue on docker with a migration hint. '
713
+ + 'Idempotent; safe to re-run.')
714
+ .option('--substrate <substrate>', 'Target substrate: kubernetes (default, alias: k3s) or docker. '
715
+ + 'Auto-detected from ~/.olam/config.json when not specified.')
716
+ .option('--cluster-name <name>', 'k3d cluster name to create/use (kubernetes substrate only; default: olam-dev). '
717
+ + 'Use a different name to avoid colliding with a pre-existing olam-dev cluster, '
718
+ + 'to run multiple olam stacks side-by-side, or for per-run unique names in CI smoke. '
719
+ + 'Must be DNS-compatible: lowercase alphanumeric, hyphens allowed in the middle.')
350
720
  .option('--skip-shell-init', 'Skip Phase 4 (do not append to ~/.zshrc / ~/.bashrc)')
351
721
  .option('--skip-auth', 'Skip Phase 6 (do not prompt for `olam auth login`)')
352
722
  .option('--skip-skill-source', 'Skip Phase 5a (do not pick a skill source; run `olam skills source add` later)')
@@ -356,9 +726,27 @@ export function registerSetup(program) {
356
726
  .option('--dry-run', 'Phase 5b: print matches without registering or building (no side effects)')
357
727
  .option('--exclude <glob...>', 'Phase 5b: additional skip patterns for project sweep')
358
728
  .option('--skip-kg', 'Phase 5b: skip KG eager-build sub-step (sources still registered)')
729
+ .option('--skip-doctor', 'Skip Phase 7 final `olam doctor` verification (for CI or minimal setups)')
359
730
  .option('-y, --yes', 'Auto-affirm every prompt (non-interactive)')
360
- .action(async (opts) => {
361
- const report = await runSetup(opts);
731
+ .action(async (rawOpts) => {
732
+ // Normalise the --substrate flag: 'k3s' is an alias for 'kubernetes'.
733
+ let substrate;
734
+ const rawSubstrate = rawOpts.substrate;
735
+ if (rawSubstrate === 'k3s' || rawSubstrate === 'kubernetes') {
736
+ substrate = 'kubernetes';
737
+ }
738
+ else if (rawSubstrate === 'docker') {
739
+ substrate = 'docker';
740
+ }
741
+ else if (rawSubstrate !== undefined) {
742
+ process.stderr.write(`[olam setup] Unknown --substrate value '${rawSubstrate}'. `
743
+ + `Valid values: docker, kubernetes (alias: k3s).\n`);
744
+ process.exitCode = 1;
745
+ return;
746
+ }
747
+ const { substrate: _drop, ...restOpts } = rawOpts;
748
+ void _drop;
749
+ const report = await runSetup({ ...restOpts, substrate });
362
750
  if (report.exitCode !== 0)
363
751
  process.exitCode = report.exitCode;
364
752
  });